{
 "cells": [
  {
   "cell_type": "code",
   "execution_count": 1,
   "id": "6e116de5",
   "metadata": {},
   "outputs": [
    {
     "name": "stdout",
     "output_type": "stream",
     "text": [
      "[env] Python: c:\\Users\\calig\\AppData\\Local\\Programs\\Python\\Python313\\python.exe\n",
      "[env] pip: 25.2\n",
      "[env] mode: online | conda:False | auto-upgrade:False\n"
     ]
    }
   ],
   "source": [
    "# Cell 0a — Environment bootstrap (safe & idempotent)\n",
    "import os, sys, subprocess\n",
    "\n",
    "PIP_MIN = (23, 1)  # only upgrade if pip is clearly very old\n",
    "OFFLINE = os.getenv(\"OFFLINE\", \"\").lower() in {\"1\", \"true\", \"yes\"}\n",
    "NO_PIP_UPGRADE = os.getenv(\"NO_PIP_UPGRADE\", \"\").lower() in {\"1\", \"true\", \"yes\"}\n",
    "\n",
    "def _ver_tuple(v: str):\n",
    "    parts = []\n",
    "    for tok in v.split(\".\"):\n",
    "        if tok.isdigit():\n",
    "            parts.append(int(tok))\n",
    "        else:\n",
    "            # drop dev/local tags like '24.0.1.dev0'\n",
    "            num = \"\".join(ch for ch in tok if ch.isdigit())\n",
    "            parts.append(int(num) if num else 0)\n",
    "    # normalize to 3 parts\n",
    "    return tuple((parts + [0, 0, 0])[:3])\n",
    "\n",
    "def _run(cmd):\n",
    "    # Keep output quiet but still fail fast if needed\n",
    "    subprocess.check_call(cmd)\n",
    "\n",
    "# 1) Ensure pip exists\n",
    "try:\n",
    "    import pip  # noqa: F401\n",
    "except Exception:\n",
    "    import ensurepip\n",
    "    ensurepip.bootstrap()  # installs pip into the current interpreter\n",
    "    import pip  # noqa: F401\n",
    "\n",
    "# 2) Upgrade pip only when reasonable\n",
    "is_conda = bool(os.environ.get(\"CONDA_PREFIX\")) or \"conda\" in sys.version.lower()\n",
    "pip_ver = _ver_tuple(getattr(pip, \"__version__\", \"0.0.0\"))\n",
    "\n",
    "should_upgrade = (\n",
    "    (pip_ver < PIP_MIN) and       # clearly outdated\n",
    "    (not is_conda) and            # avoid fighting conda-managed envs\n",
    "    (not OFFLINE) and             # respect offline runs\n",
    "    (not NO_PIP_UPGRADE)          # allow user override\n",
    ")\n",
    "\n",
    "if should_upgrade:\n",
    "    _run([sys.executable, \"-m\", \"pip\", \"install\", \"--upgrade\", \"pip\", \"--disable-pip-version-check\"])\n",
    "\n",
    "print(f\"[env] Python: {sys.executable}\")\n",
    "print(f\"[env] pip: {pip.__version__}\")\n",
    "print(f\"[env] mode: {'offline' if OFFLINE else 'online'} | conda:{is_conda} | auto-upgrade:{should_upgrade}\")\n"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": 2,
   "id": "39b65c0e",
   "metadata": {},
   "outputs": [
    {
     "name": "stdout",
     "output_type": "stream",
     "text": [
      "c:\\Users\\calig\\AppData\\Local\\Programs\\Python\\Python313\\python.exe\n",
      "[dep] pandoc: OK at C:\\Users\\calig\\AppData\\Local\\Pandoc\\pandoc.exe | pandoc 3.8\n",
      "[dep] docx2pdf: OK | soffice: C:\\Program Files\\LibreOffice\\program\\soffice.COM\n"
     ]
    }
   ],
   "source": [
    "# Cell 0b — Export toolchain (pandoc / docx2pdf), with Option A (winget/choco/scoop) + fallback\n",
    "import os, sys, platform, subprocess, shutil, importlib.util\n",
    "\n",
    "print(sys.executable)\n",
    "\n",
    "OFFLINE = os.getenv(\"OFFLINE\", \"\").lower() in {\"1\", \"true\", \"yes\"}\n",
    "PY = sys.executable\n",
    "\n",
    "def _has_module(name: str) -> bool:\n",
    "    return importlib.util.find_spec(name) is not None\n",
    "\n",
    "def _pip_install(*pkgs) -> bool:\n",
    "    if OFFLINE:\n",
    "        return False\n",
    "    try:\n",
    "        subprocess.check_call([PY, \"-m\", \"pip\", \"install\", \"--disable-pip-version-check\", *pkgs])\n",
    "        return True\n",
    "    except Exception:\n",
    "        return False\n",
    "\n",
    "def _run_ok(cmd):\n",
    "    try:\n",
    "        r = subprocess.run(cmd, check=False, capture_output=True, text=True)\n",
    "        return r.returncode == 0, r.stdout + \"\\n\" + r.stderr\n",
    "    except Exception as e:\n",
    "        return False, str(e)\n",
    "\n",
    "# --- pandoc detection strategy ---\n",
    "pandoc_path = shutil.which(\"pandoc\")\n",
    "HAS_PANDOC = bool(pandoc_path)\n",
    "\n",
    "# Option A: Try system install paths (winget → choco → scoop) if pandoc missing\n",
    "if not HAS_PANDOC and not OFFLINE and platform.system() == \"Windows\":\n",
    "    # 1) winget (silent, accept agreements)\n",
    "    ok, _ = _run_ok([\n",
    "        \"winget\", \"install\", \"--exact\", \"--silent\",\n",
    "        \"--id\", \"JohnMacFarlane.Pandoc\", \"--source\", \"winget\",\n",
    "        \"--accept-package-agreements\", \"--accept-source-agreements\"\n",
    "    ])\n",
    "    if not ok:\n",
    "        # 2) chocolatey (requires admin; if available)\n",
    "        ok, _ = _run_ok([\"choco\", \"install\", \"pandoc\", \"-y\", \"--no-progress\"])\n",
    "    if not ok:\n",
    "        # 3) scoop (user-space) if installed\n",
    "        has_scoop, _ = _run_ok([\"scoop\", \"--version\"])\n",
    "        if has_scoop:\n",
    "            ok, _ = _run_ok([\"scoop\", \"install\", \"pandoc\"])\n",
    "        else:\n",
    "            ok = False\n",
    "\n",
    "    # refresh detection\n",
    "    pandoc_path = shutil.which(\"pandoc\")\n",
    "    HAS_PANDOC = bool(pandoc_path)\n",
    "\n",
    "# If still missing and online: try pypandoc + bundled binary as a fallback\n",
    "if not HAS_PANDOC and not OFFLINE:\n",
    "    if not _has_module(\"pypandoc\"):\n",
    "        _pip_install(\"pypandoc>=1.13\")\n",
    "    if not _has_module(\"pypandoc_binary\"):\n",
    "        _pip_install(\"pypandoc-binary>=1.11\")\n",
    "    try:\n",
    "        import pypandoc\n",
    "        pandoc_path = pypandoc.get_pandoc_path()\n",
    "        HAS_PANDOC = bool(pandoc_path and os.path.exists(pandoc_path))\n",
    "        if HAS_PANDOC:\n",
    "            os.environ[\"PATH\"] = os.path.dirname(pandoc_path) + os.pathsep + os.environ.get(\"PATH\", \"\")\n",
    "    except Exception:\n",
    "        # final PATH check\n",
    "        pandoc_path = shutil.which(\"pandoc\")\n",
    "        HAS_PANDOC = bool(pandoc_path)\n",
    "\n",
    "# Optional: show pandoc version if detected\n",
    "if HAS_PANDOC:\n",
    "    try:\n",
    "        v = subprocess.check_output([\"pandoc\", \"--version\"], text=True).splitlines()[0]\n",
    "        print(f\"[dep] pandoc: OK at {pandoc_path} | {v}\")\n",
    "    except Exception:\n",
    "        print(f\"[dep] pandoc: OK at {pandoc_path}\")\n",
    "else:\n",
    "    print(\"[dep] pandoc: MISSING (use fallback)\")\n",
    "\n",
    "# --- docx2pdf detection/installation\n",
    "HAS_DOCX2PDF = _has_module(\"docx2pdf\")\n",
    "if not HAS_DOCX2PDF and not OFFLINE:\n",
    "    _pip_install(\"docx2pdf>=0.1.8\")\n",
    "    HAS_DOCX2PDF = _has_module(\"docx2pdf\")\n",
    "\n",
    "# LibreOffice (works on Windows too if installed)\n",
    "soffice_path = shutil.which(\"soffice\")\n",
    "HAS_SOFFICE = bool(soffice_path)\n",
    "\n",
    "print(f\"[dep] docx2pdf: {'OK' if HAS_DOCX2PDF else 'MISSING'} | soffice: {soffice_path or 'MISSING'}\")\n",
    "\n",
    "# Export capability flags for later cells\n",
    "os.environ[\"HAS_PANDOC\"] = \"1\" if HAS_PANDOC else \"0\"\n",
    "os.environ[\"HAS_DOCX2PDF\"] = \"1\" if HAS_DOCX2PDF else \"0\"\n",
    "os.environ[\"HAS_SOFFICE\"] = \"1\" if HAS_SOFFICE else \"0\"\n",
    "os.environ[\"PLATFORM\"] = platform.system()"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": 3,
   "id": "07e3b204",
   "metadata": {},
   "outputs": [
    {
     "name": "stdout",
     "output_type": "stream",
     "text": [
      "verify_author_calls() ready\n"
     ]
    }
   ],
   "source": [
    "# Cell 0c — Utility used by triage: verify_author_calls()\n",
    "\n",
    "from __future__ import annotations\n",
    "from pathlib import Path\n",
    "import json\n",
    "\n",
    "def verify_author_calls():\n",
    "    \"\"\"\n",
    "    Scans logs/agent_calls.jsonl for Author stage invocations and reports basic stats:\n",
    "      - total author calls\n",
    "      - cache hits vs fresh\n",
    "      - per-section distribution (author_block)\n",
    "    Prints a concise summary and returns a dict of metrics.\n",
    "    \"\"\"\n",
    "    p = Path(\"logs/agent_calls.jsonl\")\n",
    "    if not p.exists():\n",
    "        print(\"[verify] logs/agent_calls.jsonl not found.\")\n",
    "        return {}\n",
    "\n",
    "    total = 0\n",
    "    author = 0\n",
    "    editor = 0\n",
    "    research = 0\n",
    "    cache_hits = 0\n",
    "    fresh = 0\n",
    "    sections = {}\n",
    "\n",
    "    for line in p.read_text(encoding=\"utf-8\", errors=\"ignore\").splitlines():\n",
    "        line = line.strip()\n",
    "        if not line:\n",
    "            continue\n",
    "        try:\n",
    "            d = json.loads(line)\n",
    "        except Exception:\n",
    "            continue\n",
    "        total += 1\n",
    "        stage = (d.get(\"stage\") or \"\").lower()\n",
    "        agent = (d.get(\"agent\") or \"\").lower()\n",
    "        ev = d.get(\"event\") or \"\"\n",
    "\n",
    "        if agent.startswith(\"author\") or stage == \"author_block\":\n",
    "            author += 1\n",
    "        if agent.startswith(\"editor\") or stage == \"editor\":\n",
    "            editor += 1\n",
    "        if agent.startswith(\"research\") or stage == \"research\":\n",
    "            research += 1\n",
    "\n",
    "        if ev == \"cache_hit\":\n",
    "            cache_hits += 1\n",
    "        elif ev == \"llm_ok\":\n",
    "            fresh += 1\n",
    "\n",
    "        # guess section id from label like ch7_draft_s3\n",
    "        label = d.get(\"label\") or \"\"\n",
    "        m = re.search(r\"_s(\\d+)\", label)\n",
    "        if m:\n",
    "            sec = int(m.group(1))\n",
    "            sections[sec] = sections.get(sec, 0) + 1\n",
    "\n",
    "    summary = {\n",
    "        \"lines\": total,\n",
    "        \"author_calls\": author,\n",
    "        \"editor_calls\": editor,\n",
    "        \"research_calls\": research,\n",
    "        \"cache_hits\": cache_hits,\n",
    "        \"fresh_calls\": fresh,\n",
    "        \"sections_touched\": sorted(list(sections.keys())),\n",
    "    }\n",
    "    print(\"[verify] author:\", author, \"| editor:\", editor, \"| research:\", research, \"| cache_hits:\", cache_hits, \"| fresh:\", fresh)\n",
    "    if sections:\n",
    "        print(\"[verify] sections touched:\", summary[\"sections_touched\"])\n",
    "    return summary\n",
    "\n",
    "print(\"verify_author_calls() ready\")"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": 4,
   "metadata": {},
   "outputs": [
    {
     "name": "stdout",
     "output_type": "stream",
     "text": [
      "[env] ROOT: C:\\Users\\Public\\projects\\Project-A\n",
      "[env] .env loaded from C:\\Users\\Public\\projects\\Project-A\\.env\n",
      "[env] PYTHONHASHSEED=(unset)\n",
      "[openai] client ready | base_url=default | org=- | project=- | timeout=60s/request\n",
      "[openai] key=sk-p…***************************************************************************************************************************************************************\n",
      "[env] Mode: ONLINE\n",
      "[env] Ready @ 2025-09-12T22:08:20.416555Z\n"
     ]
    }
   ],
   "source": [
    "# Cell 1 — Environment, .env bootstrap, and OpenAI client (robust/deterministic)\n",
    "\n",
    "import os, sys, json, re, hashlib, shutil, zipfile, subprocess\n",
    "from pathlib import Path\n",
    "from datetime import datetime, timezone\n",
    "\n",
    "# -------------------- Paths & Time --------------------\n",
    "ROOT = Path(\".\").resolve()\n",
    "print(f\"[env] ROOT: {ROOT}\")\n",
    "\n",
    "def now_utc_iso() -> str:\n",
    "    return datetime.now(timezone.utc).isoformat().replace(\"+00:00\", \"Z\")\n",
    "\n",
    "# Create canonical run dirs early (idempotent)\n",
    "for p in [\n",
    "    \"logs\", \"cache\", \"dist\",\n",
    "    \"content/outline\", \"content/edits\",\n",
    "    \"references\",\n",
    "    \"build\"\n",
    "]:\n",
    "    (ROOT / p).mkdir(parents=True, exist_ok=True)\n",
    "\n",
    "# -------------------- Env helpers --------------------\n",
    "def _env_bool(name: str, default=False) -> bool:\n",
    "    v = os.getenv(name)\n",
    "    if v is None:\n",
    "        return default\n",
    "    return v.strip().lower() in {\"1\", \"true\", \"yes\", \"on\"}\n",
    "\n",
    "def _mask(s: str, keep: int = 4) -> str:\n",
    "    if not s:\n",
    "        return \"\"\n",
    "    return s[:keep] + \"…\" + (\"*\" * max(0, len(s) - keep - 1))\n",
    "\n",
    "OFFLINE = _env_bool(\"OFFLINE\", False)\n",
    "AUTO_INSTALL_OPENAI = _env_bool(\"AUTO_INSTALL_OPENAI\", True) and not OFFLINE\n",
    "AUTO_INSTALL_DOTENV = _env_bool(\"AUTO_INSTALL_DOTENV\", True)\n",
    "\n",
    "# -------------------- .env loading --------------------\n",
    "# If you have a .env, load it before reading OPENAI_*\n",
    "def _ensure_dotenv_loaded():\n",
    "    try:\n",
    "        from dotenv import load_dotenv\n",
    "    except Exception:\n",
    "        if AUTO_INSTALL_DOTENV:\n",
    "            print(\"[setup] Installing python-dotenv…\")\n",
    "            subprocess.run(\n",
    "                [sys.executable, \"-m\", \"pip\", \"install\", \"--quiet\", \"python-dotenv>=1.0.0,<2\"],\n",
    "                check=False,\n",
    "            )\n",
    "            try:\n",
    "                from dotenv import load_dotenv\n",
    "            except Exception as e:\n",
    "                print(f\"[warn] python-dotenv not available after install: {e}\")\n",
    "                return\n",
    "        else:\n",
    "            return\n",
    "    # Load .env if present in ROOT\n",
    "    env_path = ROOT / \".env\"\n",
    "    if env_path.exists():\n",
    "        load_dotenv(dotenv_path=env_path, override=False)\n",
    "        print(f\"[env] .env loaded from {env_path}\")\n",
    "    else:\n",
    "        print(\"[env] No .env file found (skipping)\")\n",
    "\n",
    "_ensure_dotenv_loaded()\n",
    "\n",
    "# -------------------- Determinism postures --------------------\n",
    "# Encourage run-level determinism (Python hash seed). We don't force-set it here,\n",
    "# but we log it so differences are visible in manifests.\n",
    "PY_HASH_SEED = os.getenv(\"PYTHONHASHSEED\")\n",
    "print(f\"[env] PYTHONHASHSEED={PY_HASH_SEED or '(unset)'}\")\n",
    "\n",
    "def stable_json_dumps(obj) -> str:\n",
    "    \"\"\"Stable JSON for hashing & cache keys.\"\"\"\n",
    "    return json.dumps(obj, sort_keys=True, separators=(\",\", \":\"))\n",
    "\n",
    "def sha1(data: str) -> str:\n",
    "    return hashlib.sha1(data.encode(\"utf-8\")).hexdigest()\n",
    "\n",
    "# -------------------- OpenAI client (optional if OFFLINE) --------------------\n",
    "client = None\n",
    "timeout_s = int(os.getenv(\"OPENAI_TIMEOUT\", \"60\"))  # applied per-request\n",
    "\n",
    "if OFFLINE:\n",
    "    print(\"[mode] OFFLINE: Skipping OpenAI client initialization.\")\n",
    "else:\n",
    "    # Defer import/installation until after .env is loaded\n",
    "    try:\n",
    "        from openai import OpenAI  # >=1.x series\n",
    "    except Exception:\n",
    "        if AUTO_INSTALL_OPENAI:\n",
    "            print(\"[setup] Installing openai SDK…\")\n",
    "            subprocess.run(\n",
    "                [sys.executable, \"-m\", \"pip\", \"install\", \"--quiet\", \"openai>=1.40.0,<2\"],\n",
    "                check=False,\n",
    "            )\n",
    "            try:\n",
    "                from openai import OpenAI\n",
    "            except Exception as e:\n",
    "                raise SystemExit(f\"ERROR: OpenAI SDK not available after install: {e}\")\n",
    "        else:\n",
    "            raise SystemExit(\n",
    "                \"ERROR: OpenAI SDK missing and AUTO_INSTALL_OPENAI=0. \"\n",
    "                \"Install 'openai' or enable auto-install.\"\n",
    "            )\n",
    "\n",
    "    api_key = os.getenv(\"OPENAI_API_KEY\")\n",
    "    base_url = os.getenv(\"OPENAI_BASE_URL\")  # optional (for gateways/self-hosting)\n",
    "    organization = os.getenv(\"OPENAI_ORG\") or os.getenv(\"OPENAI_ORGANIZATION\")\n",
    "    project = os.getenv(\"OPENAI_PROJECT\")  # optional\n",
    "\n",
    "    if not api_key:\n",
    "        raise SystemExit(\"ERROR: OPENAI_API_KEY not set. Add it to your environment or .env\")\n",
    "\n",
    "    try:\n",
    "        client_kwargs = {\"api_key\": api_key}\n",
    "        if base_url:\n",
    "            client_kwargs[\"base_url\"] = base_url\n",
    "        if organization:\n",
    "            client_kwargs[\"organization\"] = organization\n",
    "        if project:\n",
    "            client_kwargs[\"project\"] = project\n",
    "\n",
    "        client = OpenAI(**client_kwargs)\n",
    "        # We’ll pass request-level timeout per call; some transports also allow with_options.\n",
    "        print(\n",
    "            \"[openai] client ready | \"\n",
    "            f\"base_url={base_url or 'default'} | \"\n",
    "            f\"org={organization or '-'} | project={project or '-'} | \"\n",
    "            f\"timeout={timeout_s}s/request\"\n",
    "        )\n",
    "        print(f\"[openai] key={_mask(api_key)}\")\n",
    "    except Exception as e:\n",
    "        raise SystemExit(f\"ERROR: Could not initialize OpenAI client: {e}\")\n",
    "\n",
    "# Centralized request options for per-call usage (e.g., client.responses.create(**REQUEST_OPTS, ...))\n",
    "REQUEST_OPTS = {\"timeout\": timeout_s}\n",
    "\n",
    "# -------------------- Run banner --------------------\n",
    "print(\"[env] Mode:\", \"OFFLINE\" if OFFLINE else \"ONLINE\")\n",
    "print(\"[env] Ready @\", now_utc_iso())"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": 5,
   "metadata": {},
   "outputs": [
    {
     "name": "stdout",
     "output_type": "stream",
     "text": [
      "[config] kind: fiction | pack: thriller | style: conversational | cite: none\n",
      "[config] MODEL_FAST: gpt-4o | MODEL_THINK: gpt-5 | TEMP: 0.2\n",
      "[config] tokens(author/editor/research/outline): 10000 / 16384 / 5500 / 5500\n",
      "[config] cost caps (run/chapter): $ 3.0 /$ 0.25\n",
      "[config] outline schema: thriller_beats_v3\n",
      "[config] gates: {'value_shift': True, 'cliffhanger': True, 'voice_enforce': True, 'cliche_sweep': True, 'redundancy_sweep': True, 'antagonist_competence': True}\n",
      "[config] PROMPT_VERSIONS: {'author': 'v4', 'editor': 'v3', 'research': 'v2', 'router': 'v2', 'outline': 'v3_thriller'}\n"
     ]
    }
   ],
   "source": [
    "# Cell 2 — Configuration (generalized: fiction/nonfiction packs, env-overridable, deterministic)\n",
    "\n",
    "import os\n",
    "from pathlib import Path\n",
    "from dataclasses import dataclass, asdict\n",
    "from typing import Dict, Any, List\n",
    "\n",
    "# -------------------- Ensure project dirs exist (idempotent) --------------------\n",
    "DIRS = [\n",
    "    \"content/research\",\n",
    "    \"content/drafts\",\n",
    "    \"content/edits\",\n",
    "    \"content/outline\",\n",
    "    \"content/style\",\n",
    "    \"content/research_inputs\",\n",
    "    \"build\",\n",
    "    \"dist\",\n",
    "    \"logs\",\n",
    "    \"references\",\n",
    "    \"cache\",\n",
    "]\n",
    "for d in DIRS:\n",
    "    Path(d).mkdir(parents=True, exist_ok=True)\n",
    "\n",
    "# -------------------- Small env helpers --------------------\n",
    "def _env_bool(k: str, default: bool = False) -> bool:\n",
    "    v = os.getenv(k)\n",
    "    if v is None:\n",
    "        return default\n",
    "    return v.strip().lower() in {\"1\", \"true\", \"yes\", \"on\"}\n",
    "\n",
    "def _env_int(k: str, default: int) -> int:\n",
    "    try:\n",
    "        return int((os.getenv(k) or \"\").replace(\"_\", \"\").strip() or default)\n",
    "    except Exception:\n",
    "        return default\n",
    "\n",
    "def _env_float(k: str, default: float) -> float:\n",
    "    try:\n",
    "        return float((os.getenv(k) or \"\").strip() or default)\n",
    "    except Exception:\n",
    "        return default\n",
    "\n",
    "def _env_str(k: str, default: str) -> str:\n",
    "    v = os.getenv(k)\n",
    "    return v if v is not None and v.strip() != \"\" else default\n",
    "\n",
    "def _env_csv(k: str, default: List[str]) -> List[str]:\n",
    "    v = os.getenv(k)\n",
    "    if not v:\n",
    "        return default\n",
    "    return [s.strip() for s in v.split(\",\") if s.strip()]\n",
    "\n",
    "# -------------------- Pack registries (expandable) --------------------\n",
    "FICTION_GENRE_PACKS = {\n",
    "    \"thriller\": \"thriller_beats_v3\",\n",
    "    \"mystery\": \"mystery_puzzle_v2\",\n",
    "    \"romance\": \"romance_beat_sheet_v2\",\n",
    "    \"fantasy\": \"hero_journey_v2\",\n",
    "    \"sci_fi\": \"sf_adventure_v2\",\n",
    "    \"literary\": \"literary_arc_v1\",\n",
    "    \"children\": \"children_story_v1\",\n",
    "    \"ya\": \"ya_arc_v1\",\n",
    "}\n",
    "\n",
    "NONFICTION_STRUCTURE_PACKS = {\n",
    "    \"howto\": \"howto_schema_v2\",\n",
    "    \"case_study\": \"business_case_v2\",\n",
    "    \"essay\": \"essay_argument_v2\",\n",
    "    \"report\": \"report_structure_v2\",\n",
    "    \"memoir\": \"memoir_narrative_v1\",\n",
    "    \"history\": \"history_narrative_v2\",\n",
    "}\n",
    "\n",
    "STYLE_PACKS = {\"academic\", \"journalistic\", \"conversational\", \"literary\", \"technical\"}\n",
    "CITATION_STYLES = {\"apa\", \"chicago\", \"ieee\", \"none\"}\n",
    "\n",
    "# -------------------- Book kind & packs (env-overridable) --------------------\n",
    "BOOK_KIND = _env_str(\"BOOK_KIND\", \"fiction\").lower()  # fiction|nonfiction\n",
    "GENRE_PACK = _env_str(\"GENRE_PACK\", \"thriller\").lower()\n",
    "STRUCTURE_PACK = _env_str(\"STRUCTURE_PACK\", \"howto\").lower()\n",
    "STYLE_PACK = _env_str(\"STYLE_PACK\", \"conversational\").lower()\n",
    "CITATION_STYLE = _env_str(\"CITATION_STYLE\", \"none\" if BOOK_KIND == \"fiction\" else \"apa\").lower()\n",
    "\n",
    "if BOOK_KIND not in {\"fiction\", \"nonfiction\"}:\n",
    "    raise SystemExit(\"BOOK_KIND must be 'fiction' or 'nonfiction'\")\n",
    "\n",
    "if BOOK_KIND == \"fiction\" and GENRE_PACK not in FICTION_GENRE_PACKS:\n",
    "    raise SystemExit(f\"GENRE_PACK must be one of: {', '.join(FICTION_GENRE_PACKS)}\")\n",
    "\n",
    "if BOOK_KIND == \"nonfiction\" and STRUCTURE_PACK not in NONFICTION_STRUCTURE_PACKS:\n",
    "    raise SystemExit(f\"STRUCTURE_PACK must be one of: {', '.join(NONFICTION_STRUCTURE_PACKS)}\")\n",
    "\n",
    "if STYLE_PACK not in STYLE_PACKS:\n",
    "    raise SystemExit(f\"STYLE_PACK must be one of: {', '.join(sorted(STYLE_PACKS))}\")\n",
    "\n",
    "if CITATION_STYLE not in CITATION_STYLES:\n",
    "    raise SystemExit(f\"CITATION_STYLE must be one of: {', '.join(sorted(CITATION_STYLES))}\")\n",
    "\n",
    "# Derive outline schema from selected pack\n",
    "if BOOK_KIND == \"fiction\":\n",
    "    OUTLINE_SCHEMA = FICTION_GENRE_PACKS[GENRE_PACK]\n",
    "else:\n",
    "    OUTLINE_SCHEMA = NONFICTION_STRUCTURE_PACKS[STRUCTURE_PACK]\n",
    "\n",
    "# -------------------- Story asset defaults (thriller-native but generalizable) --------------------\n",
    "default_characters = [\n",
    "    {\"name\": \"Alex Reyes\", \"role\": \"former NSA cyber-operative turned rogue agent\", \"traits\": [\"analytical\", \"haunted\", \"resourceful\"]},\n",
    "    {\"name\": \"Eira Kwon\", \"role\": \"black-hat hacker, founder of The Nulls\", \"traits\": [\"reckless\", \"genius-level coder\", \"mistrustful\"]},\n",
    "    {\"name\": \"Director Harland Vey\", \"role\": \"antagonist, head of the Oracle Division\", \"traits\": [\"calculating\", \"charismatic\", \"fanatically loyal to the AI\"]},\n",
    "    {\"name\": \"The Oracle\", \"role\": \"sentient predictive AI, ambiguous intentions\", \"traits\": [\"omniscient\", \"unpredictable\", \"eerily calm\"]},\n",
    "]\n",
    "\n",
    "default_outline_seed_thriller = [\n",
    "    \"Trigger Protocol\",\"The Whispering Code\",\"Neural Keys\",\"Ghosting the Oracle\",\"Tokyo Blackout\",\"Zero-Day Firestorm\",\n",
    "    \"Signals from the Nulls\",\"The Frame Job\",\"Fractured Loyalties\",\"Synthetic Betrayal\",\"The Infinite Loop\",\"Blood in the Data Streams\",\n",
    "    \"Exfiltration Protocol\",\"When Futures Collide\",\"The Oracle's Dilemma\",\"Null State\",\"Collapse Point\",\"Reboot\",\n",
    "]\n",
    "\n",
    "# -------------------- Book spec (env-overridable core) --------------------\n",
    "book_spec: Dict[str, Any] = {\n",
    "    \"kind\": BOOK_KIND,  # fiction|nonfiction\n",
    "    \"genre_pack\": GENRE_PACK if BOOK_KIND == \"fiction\" else None,\n",
    "    \"structure_pack\": STRUCTURE_PACK if BOOK_KIND == \"nonfiction\" else None,\n",
    "    \"style_pack\": STYLE_PACK,\n",
    "    \"citation_style\": CITATION_STYLE,\n",
    "\n",
    "    \"title\": _env_str(\"BOOK_TITLE\", \"Ghost Protocol: The Oracle Gambit\" if BOOK_KIND == \"fiction\" else \"Build Once, Scale Forever\"),\n",
    "    \"subtitle\": _env_str(\n",
    "        \"BOOK_SUBTITLE\",\n",
    "        \"A Cyber-Espionage Thriller About Free Will, AI, and Betrayal\"\n",
    "        if BOOK_KIND == \"fiction\"\n",
    "        else \"A Practical Playbook for Durable Systems, Teams, and Strategy\",\n",
    "    ),\n",
    "    \"author\": _env_str(\"BOOK_AUTHOR\", \"Your Name\"),\n",
    "    \"audience\": _env_str(\n",
    "        \"BOOK_AUDIENCE\",\n",
    "        \"Adult readers who enjoy techno-thrillers, espionage, and high-stakes conspiracies\"\n",
    "        if BOOK_KIND == \"fiction\"\n",
    "        else \"Practitioners and leaders seeking clear, cited guidance\",\n",
    "    ),\n",
    "    \"goal\": _env_str(\n",
    "        \"BOOK_GOAL\",\n",
    "        \"Deliver a heart-pounding thriller combining espionage, AI ethics, and hidden conspiracies, exploring free will vs algorithmic control.\"\n",
    "        if BOOK_KIND == \"fiction\"\n",
    "        else \"Deliver a credible, well-cited manuscript that advances a clear thesis with actionable frameworks.\",\n",
    "    ),\n",
    "    \"genre\": _env_str(\"BOOK_GENRE\", \"Techno-thriller / Spy Fiction\" if BOOK_KIND == \"fiction\" else \"Nonfiction\"),\n",
    "    \"tone\": _env_str(\n",
    "        \"BOOK_TONE\",\n",
    "        \"Dark, tense, cerebral, action-driven, morally complex\" if BOOK_KIND == \"fiction\" else \"Clear, direct, evidence-driven\"\n",
    "    ),\n",
    "    \"reading_level\": _env_str(\"BOOK_READING_LEVEL\", \"Adult (17+)\"),\n",
    "    \"target_length_words\": _env_int(\"BOOK_WORDS\", 95_000 if BOOK_KIND == \"fiction\" else 60_000),\n",
    "    \"chapters\": _env_int(\"BOOK_CHAPTERS\", 18 if BOOK_KIND == \"fiction\" else 12),\n",
    "\n",
    "    \"outline_schema\": OUTLINE_SCHEMA,\n",
    "    \"outline_constraints\": _env_csv(\n",
    "        \"OUTLINE_CONSTRAINTS\",\n",
    "        [\n",
    "            # Fiction defaults emphasize tension and reveal-through-action\n",
    "            \"Three-act structure or equivalent pacing appropriate to selected pack\",\n",
    "            \"Cliffhanger or momentum at most chapter endings\",\n",
    "            \"Foreshadow/payoff alignment; minimize exposition dumps\",\n",
    "            \"Continuity of POV voice and timeline\",\n",
    "        ]\n",
    "        if BOOK_KIND == \"fiction\"\n",
    "        else [\n",
    "            # Nonfiction defaults emphasize claim-evidence clarity\n",
    "            \"Logical progression from thesis → claims → evidence → counterpoints → takeaway\",\n",
    "            \"Figures/tables placed near first reference\",\n",
    "            \"Terminology defined once; reused consistently\",\n",
    "        ]\n",
    "    ),\n",
    "\n",
    "    \"style_guide\": {\n",
    "        \"voice\": _env_str(\n",
    "            \"STYLE_VOICE\",\n",
    "            \"Third-person limited, rotating POV; past tense\" if BOOK_KIND == \"fiction\" else \"Direct, active voice; minimal jargon\"\n",
    "        ),\n",
    "        \"formatting\": _env_str(\n",
    "            \"STYLE_FORMATTING\",\n",
    "            \"Markdown: H2 chapters, H3 subsections; italics for flashbacks/inner monologues; short paragraphs.\"\n",
    "            if BOOK_KIND == \"fiction\"\n",
    "            else \"Markdown: H2 chapters, H3 sections; consistent heading hierarchy; numbered figures/tables.\"\n",
    "        ),\n",
    "        \"citations\": \"Not applicable (fiction).\" if BOOK_KIND == \"fiction\" else f\"{CITATION_STYLE.upper()} for in-text and bibliography.\",\n",
    "        \"terminology\": _env_csv(\n",
    "            \"STYLE_TERMS\",\n",
    "            ([\n",
    "                \"oracle: a predictive AI that simulates futures\",\n",
    "                \"ghosting: disappearing from all digital networks\",\n",
    "                \"neural key: encrypted memory implant\",\n",
    "                \"deepnet: unindexed AI-controlled layers beyond the dark web\",\n",
    "            ] if BOOK_KIND == \"fiction\" else [])\n",
    "        ),\n",
    "    },\n",
    "\n",
    "    \"research_policy\": {\n",
    "        \"enabled\": _env_bool(\"fv\", True),\n",
    "        # Selective: only for editor-tagged [n]/claims; the ResearchAgent returns JSON summaries & minimal citations\n",
    "        \"selective\": True,\n",
    "        \"citation_format\": CITATION_STYLE if BOOK_KIND == \"nonfiction\" else \"none\",\n",
    "        \"sources_allowed\": _env_csv(\n",
    "            \"RESEARCH_SOURCES_OK\",\n",
    "            ([\n",
    "                \"declassified espionage tactics\",\n",
    "                \"AI ethics research papers\",\n",
    "                \"cybersecurity best practices\",\n",
    "                \"public-domain intelligence archives\",\n",
    "            ] if BOOK_KIND == \"fiction\" else [\n",
    "                \"peer-reviewed articles\",\n",
    "                \"official statistics\",\n",
    "                \"government/NGO reports\",\n",
    "                \"company filings\",\n",
    "            ])\n",
    "        ),\n",
    "        \"sources_disallowed\": _env_csv(\n",
    "            \"RESEARCH_SOURCES_NO\",\n",
    "            [\"real classified documents\", \"leaked materials\", \"malware samples\"]\n",
    "        ),\n",
    "    },\n",
    "\n",
    "    \"constraints\": {\n",
    "        \"originality\": \"All prose must be new/unique; paraphrase-first posture.\",\n",
    "        \"copyright\": \"No copyrighted fictional characters/logos; quotes require citation (nonfiction) or in-world attribution (fiction).\",\n",
    "        \"age_appropriateness\": _env_str(\"AGE_POLICY\", \"Adult themes allowed; avoid gratuitous gore/exploitation.\"),\n",
    "        \"representation\": \"International cast/perspectives; avoid stereotypes.\",\n",
    "        \"localization\": _env_str(\"LOCALE\", \"en-US\"),\n",
    "    },\n",
    "\n",
    "    \"export\": {\n",
    "        \"docx\": _env_bool(\"EXPORT_DOCX\", True),\n",
    "        \"epub\": _env_bool(\"EXPORT_EPUB\", True),\n",
    "        \"pdf\": _env_bool(\"EXPORT_PDF\", True),\n",
    "    },\n",
    "\n",
    "    \"story_assets\": {\n",
    "        \"setting\": _env_str(\n",
    "            \"SETTING_TEXT\",\n",
    "            \"Near-future, hyper-connected world influenced by predictive AIs; set pieces: neo-Tokyo, abandoned Soviet site, orbital data center, underground network.\"\n",
    "            if BOOK_KIND == \"fiction\" else \"\"\n",
    "        ),\n",
    "        \"characters\": default_characters if BOOK_KIND == \"fiction\" else [],\n",
    "        \"motifs\": [\n",
    "            \"green cascading code overlays\",\n",
    "            \"mirrors/reflections (identity duality)\",\n",
    "            \"flickering neon during confrontations\",\n",
    "            \"repeating drowning dreams (info-overload)\",\n",
    "        ] if BOOK_KIND == \"fiction\" else [],\n",
    "        \"themes\": _env_csv(\n",
    "            \"THEMES\",\n",
    "            ([\"Free will vs determinism\", \"Surveillance vs autonomy\", \"Trust & betrayal\", \"Ethics of machine sentience\"]\n",
    "             if BOOK_KIND == \"fiction\" else\n",
    "             [\"Clarity vs complexity\", \"Evidence over opinion\", \"Systems thinking\"])\n",
    "        ),\n",
    "    },\n",
    "\n",
    "    \"outline_seed\": default_outline_seed_thriller if BOOK_KIND == \"fiction\" else [],\n",
    "    \"chapter_template\": {\n",
    "        \"sections\": (\n",
    "            [\n",
    "                \"Opening hook scene\",\n",
    "                \"Problem escalation\",\n",
    "                \"Twist or revelation\",\n",
    "                \"Action or moral dilemma\",\n",
    "                \"Cliffhanger or resolution\",\n",
    "            ] if BOOK_KIND == \"fiction\" else [\n",
    "                \"Introduction\",\n",
    "                \"Argument\",\n",
    "                \"Evidence/Case\",\n",
    "                \"Counterpoint\",\n",
    "                \"Summary/Takeaway\",\n",
    "            ]\n",
    "        ),\n",
    "        \"end_matter\": (\n",
    "            [\n",
    "                \"Encrypted Epigraph\",\n",
    "                \"Tech Glossary Entry\",\n",
    "                \"Foreshadowing Symbol or Clue\",\n",
    "            ] if BOOK_KIND == \"fiction\" else [\n",
    "                \"Key Takeaways\",\n",
    "                \"Cited Works Added\",\n",
    "                \"Open Questions\",\n",
    "            ]\n",
    "        ),\n",
    "        \"chapter_heading_format\": _env_str(\"CHAPTER_HEADING_FMT\", \"## Chapter {number}: {title}\"),\n",
    "        \"render_section_headings\": _env_bool(\"RENDER_SECTION_HEADINGS\", BOOK_KIND != \"fiction\"),\n",
    "        \"number_sections\": _env_bool(\"NUMBER_SECTIONS\", True),\n",
    "        \"section_heading_format\": _env_str(\"SECTION_HEADING_FMT\", \"### {index}. {title}\"),\n",
    "        \"end_matter_heading_format\": _env_str(\"END_MATTER_HEADING_FMT\", \"### {label}\"),\n",
    "    },\n",
    "}\n",
    "\n",
    "# -------------------- Pipeline configuration (budget, models, tokens, gates) --------------------\n",
    "pipeline_config: Dict[str, Any] = {\n",
    "    # Cost envelopes (reserve → reconcile)\n",
    "    \"RUN_COST_CAP_USD\": _env_float(\"RUN_COST_CAP_USD\", 3.00),\n",
    "    \"CHAPTER_COST_CAP_USD\": _env_float(\"CHAPTER_COST_CAP_USD\", 0.25),\n",
    "\n",
    "    # Models & temps (fast vs think)\n",
    "    \"MODEL_ID_FAST\": _env_str(\"MODEL_ID_FAST\", \"gpt-4o\"),\n",
    "    \"MODEL_ID_THINK\": _env_str(\"MODEL_ID_THINK\", \"gpt-5\"),\n",
    "    \"TEMPERATURE\": _env_float(\"TEMPERATURE\", 0.2),\n",
    "\n",
    "    # Run mode\n",
    "    \"SAMPLE_RUN_CHAPTERS\": _env_int(\"SAMPLE_RUN_CHAPTERS\", 1),  # 0 = disabled\n",
    "    \"FULL_RUN\": _env_bool(\"FULL_RUN\", True),\n",
    "    \"ULTRA_BUDGET_MODE\": _env_bool(\"ULTRA_BUDGET_MODE\", False),\n",
    "\n",
    "    # Token budgets (author/editor/research/outline)\n",
    "    # NOTE: Router should pass these as max_t per call to be the *governing* caps.\n",
    "    \"AUTHOR_MAX_TOKENS\": _env_int(\"AUTHOR_MAX_TOKENS\", 10000 if BOOK_KIND == \"fiction\" else 10000),\n",
    "    \"EDITOR_MAX_TOKENS\": _env_int(\"EDITOR_MAX_TOKENS\", 16384),\n",
    "    \"EDITOR_MIN_TOKENS\": _env_int(\"EDITOR_MIN_TOKENS\", 16384),\n",
    "    \"EDITOR_CONTEXT_TOKENS\": _env_int(\"EDITOR_CONTEXT_TOKENS\", 128_000),\n",
    "    \"EDITOR_SAFE_MARGIN_TOKENS\": _env_int(\"EDITOR_SAFE_MARGIN_TOKENS\", 1000),\n",
    "    \"EDITOR_MAX_INPUT_CHARS\": _env_int(\"EDITOR_MAX_INPUT_CHARS\", 300_000),\n",
    "    \"RESEARCH_MAX_TOKENS\": _env_int(\"RESEARCH_MAX_TOKENS\", 5500 if BOOK_KIND == \"fiction\" else 5500),\n",
    "    \"OUTLINE_MAX_TOKENS\": _env_int(\"OUTLINE_MAX_TOKENS\", 5500),\n",
    "\n",
    "    # Word target tolerance (±)\n",
    "    \"CHAPTER_TOLERANCE_PCT\": _env_float(\"CHAPTER_TOLERANCE_PCT\", 0.18),\n",
    "\n",
    "    # Research switch (selective routing later)\n",
    "    \"RESEARCH_ENABLED\": book_spec[\"research_policy\"][\"enabled\"],\n",
    "\n",
    "    # Quality gates (toggle per run)\n",
    "    \"GATES_FICTION\": {\n",
    "        \"value_shift\": _env_bool(\"GATE_VALUE_SHIFT\", True),\n",
    "        \"cliffhanger\": _env_bool(\"GATE_CLIFFHANGER\", True),\n",
    "        \"voice_enforce\": _env_bool(\"GATE_VOICE\", True),\n",
    "        \"cliche_sweep\": _env_bool(\"GATE_CLICHE\", True),\n",
    "        \"redundancy_sweep\": _env_bool(\"GATE_REDUNDANCY\", True),\n",
    "        \"antagonist_competence\": _env_bool(\"GATE_ANTAGONIST\", True),\n",
    "    },\n",
    "    \"GATES_NONFICTION\": {\n",
    "        \"claim_evidence\": _env_bool(\"GATE_CLAIM_EVIDENCE\", True),\n",
    "        \"logic_flow\": _env_bool(\"GATE_LOGIC\", True),\n",
    "        \"citation_hygiene\": _env_bool(\"GATE_CITATION_HYGIENE\", True),\n",
    "        \"redundancy_sweep\": _env_bool(\"GATE_REDUNDANCY\", True),\n",
    "        \"terminology_consistency\": _env_bool(\"GATE_TERMINOLOGY\", True),\n",
    "    },\n",
    "\n",
    "    # Prompt/version stamps (cache coherence)\n",
    "    \"PROMPT_VERSIONS\": {\n",
    "        \"author\": _env_str(\"PV_AUTHOR\", \"v4\"),\n",
    "        \"editor\": _env_str(\"PV_EDITOR\", \"v3\"),\n",
    "        \"research\": _env_str(\"PV_RESEARCH\", \"v2\"),\n",
    "        \"router\": _env_str(\"PV_ROUTER\", \"v2\"),\n",
    "        \"outline\": _env_str(\"PV_OUTLINE\", f\"v3_{GENRE_PACK}\" if BOOK_KIND == \"fiction\" else f\"v3_{STRUCTURE_PACK}\"),\n",
    "    },\n",
    "\n",
    "    # Cache shaping knobs\n",
    "    \"CACHE_PAYLOAD_FIELDS\": [\n",
    "        \"pv\", \"kind\", \"pack\", \"ch\", \"sec\", \"oc_min\", \"tw\", \"kb_hash\", \"bible_hash\"\n",
    "    ],\n",
    "    \"CACHE_PREFIXES\": {\n",
    "        \"outline\": \"oln\",\n",
    "        \"author_block\": \"ab\",\n",
    "        \"author_tighten\": \"at\",\n",
    "        \"editor\": \"ed\",\n",
    "        \"research\": \"rs\",\n",
    "    },\n",
    "}\n",
    "\n",
    "# Compute effective gates for this run (union minimized to kind-relevant)\n",
    "if BOOK_KIND == \"fiction\":\n",
    "    EFFECTIVE_GATES = pipeline_config[\"GATES_FICTION\"]\n",
    "else:\n",
    "    EFFECTIVE_GATES = pipeline_config[\"GATES_NONFICTION\"]\n",
    "\n",
    "# -------------------- Ultra-budget posture (optional) --------------------\n",
    "if pipeline_config[\"ULTRA_BUDGET_MODE\"]:\n",
    "    # Trim tokens and relax optional gates\n",
    "    pipeline_config[\"AUTHOR_MAX_TOKENS\"] = max(1500, int(pipeline_config[\"AUTHOR_MAX_TOKENS\"] * 0.5))\n",
    "    pipeline_config[\"EDITOR_MAX_TOKENS\"] = max(4000, int(pipeline_config[\"EDITOR_MAX_TOKENS\"] * 0.5))\n",
    "    pipeline_config[\"RESEARCH_MAX_TOKENS\"] = max(800, int(pipeline_config[\"RESEARCH_MAX_TOKENS\"] * 0.6))\n",
    "    pipeline_config[\"OUTLINE_MAX_TOKENS\"] = max(900, int(pipeline_config[\"OUTLINE_MAX_TOKENS\"] * 0.6))\n",
    "    pipeline_config[\"RUN_COST_CAP_USD\"] = min(pipeline_config[\"RUN_COST_CAP_USD\"], 2.00)\n",
    "    pipeline_config[\"CHAPTER_COST_CAP_USD\"] = min(pipeline_config[\"CHAPTER_COST_CAP_USD\"], 0.15)\n",
    "\n",
    "    if BOOK_KIND == \"fiction\":\n",
    "        for k in (\"cliche_sweep\", \"redundancy_sweep\", \"voice_enforce\"):\n",
    "            EFFECTIVE_GATES[k] = False\n",
    "    else:\n",
    "        for k in (\"redundancy_sweep\",):\n",
    "            EFFECTIVE_GATES[k] = False\n",
    "\n",
    "# -------------------- Paths & references --------------------\n",
    "paths: Dict[str, str] = {\n",
    "    \"outline_json\": \"content/outline/outline.json\",\n",
    "    \"outline_md\": \"content/outline/outline.md\",\n",
    "    \"story_bible\": \"references/story_bible.json\",         # fiction\n",
    "    \"knowledge_base\": \"references/knowledge_base.json\",   # nonfiction\n",
    "    \"agent_calls\": \"logs/agent_calls.jsonl\",\n",
    "    \"run_manifest\": \"logs/run_manifest.json\",\n",
    "    \"cost_log\": \"logs/cost.json\",\n",
    "    \"build_md\": \"build/book.md\",\n",
    "    \"dist_epub\": \"dist/book.epub\",\n",
    "    \"dist_pdf\": \"dist/book.pdf\",\n",
    "    \"dist_docx\": \"dist/book.docx\",\n",
    "    \"csl_style\": _env_str(\"CSL_STYLE_PATH\", \"references/apa.csl\"),  # used when CITATION_STYLE != none\n",
    "}\n",
    "\n",
    "# -------------------- Print a concise banner --------------------\n",
    "print(\"[config] kind:\", book_spec[\"kind\"], \"| pack:\", book_spec.get(\"genre_pack\") or book_spec.get(\"structure_pack\"), \"| style:\", STYLE_PACK, \"| cite:\", CITATION_STYLE)\n",
    "print(\"[config] MODEL_FAST:\", pipeline_config[\"MODEL_ID_FAST\"], \"| MODEL_THINK:\", pipeline_config[\"MODEL_ID_THINK\"], \"| TEMP:\", pipeline_config[\"TEMPERATURE\"])\n",
    "print(\"[config] tokens(author/editor/research/outline):\",\n",
    "      pipeline_config[\"AUTHOR_MAX_TOKENS\"], \"/\",\n",
    "      pipeline_config[\"EDITOR_MAX_TOKENS\"], \"/\",\n",
    "      pipeline_config[\"RESEARCH_MAX_TOKENS\"], \"/\",\n",
    "      pipeline_config[\"OUTLINE_MAX_TOKENS\"])\n",
    "print(\"[config] cost caps (run/chapter): $\", pipeline_config[\"RUN_COST_CAP_USD\"], \"/$\", pipeline_config[\"CHAPTER_COST_CAP_USD\"])\n",
    "print(\"[config] outline schema:\", book_spec[\"outline_schema\"])\n",
    "print(\"[config] gates:\", EFFECTIVE_GATES)\n",
    "print(\"[config] PROMPT_VERSIONS:\", pipeline_config[\"PROMPT_VERSIONS\"])\n",
    "\n",
    "# -------------------- Expose globals the rest of the notebook will import --------------------\n",
    "CONFIG = {\n",
    "    \"book_spec\": book_spec,\n",
    "    \"pipeline\": pipeline_config,\n",
    "    \"gates\": EFFECTIVE_GATES,\n",
    "    \"paths\": paths,\n",
    "}"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": 6,
   "metadata": {},
   "outputs": [
    {
     "name": "stdout",
     "output_type": "stream",
     "text": [
      "[utils] ready — IO/sanitize/cache/cost/targets initialized\n"
     ]
    }
   ],
   "source": [
    "# Cell 3 — Utilities (deterministic, cache- & cost-aware, aligned to v2.0 Blueprint)\n",
    "\n",
    "from __future__ import annotations\n",
    "\n",
    "import os\n",
    "import re\n",
    "import json\n",
    "import hashlib\n",
    "import traceback\n",
    "from pathlib import Path\n",
    "from dataclasses import dataclass, asdict\n",
    "from typing import Any, Dict, Iterable, Optional, Tuple\n",
    "from datetime import datetime, timezone\n",
    "\n",
    "# ----------------------------------------------------------------------\n",
    "# Time & small helpers\n",
    "# ----------------------------------------------------------------------\n",
    "\n",
    "def now_utc_iso() -> str:\n",
    "    \"\"\"UTC timestamp in ISO-8601 Z form (deterministic string for logs/manifests).\"\"\"\n",
    "    return datetime.now(timezone.utc).isoformat().replace(\"+00:00\", \"Z\")\n",
    "\n",
    "def _safe_label(s: str, max_len: int = 64) -> str:\n",
    "    \"\"\"Filesystem-safe label (for log filenames / cache prefixes).\"\"\"\n",
    "    s = (s or \"\").strip().lower()\n",
    "    s = re.sub(r\"[^a-z0-9_\\-\\.]+\", \"_\", s)\n",
    "    return s[:max_len] or \"run\"\n",
    "\n",
    "def _safe_int(v: Any, default: int) -> int:\n",
    "    try:\n",
    "        return int(v)\n",
    "    except Exception:\n",
    "        return int(default)\n",
    "\n",
    "def _safe_float(v: Any, default: float) -> float:\n",
    "    try:\n",
    "        return float(v)\n",
    "    except Exception:\n",
    "        return float(default)\n",
    "\n",
    "def stable_json_dumps(obj: Any) -> str:\n",
    "    \"\"\"Stable JSON string for hashing/cache keys.\"\"\"\n",
    "    return json.dumps(obj, sort_keys=True, separators=(\",\", \":\"))\n",
    "\n",
    "def sha1(s: str) -> str:\n",
    "    return hashlib.sha1((s or \"\").encode(\"utf-8\")).hexdigest()\n",
    "\n",
    "# ----------------------------------------------------------------------\n",
    "# File I/O (idempotent, UTF-8 safe)\n",
    "# ----------------------------------------------------------------------\n",
    "\n",
    "def read_text(p: os.PathLike | str) -> str:\n",
    "    p = Path(p)\n",
    "    return p.read_text(encoding=\"utf-8\") if p.exists() else \"\"\n",
    "\n",
    "def write_text(p: os.PathLike | str, s: str) -> None:\n",
    "    p = Path(p)\n",
    "    p.parent.mkdir(parents=True, exist_ok=True)\n",
    "    p.write_text(s or \"\", encoding=\"utf-8\")\n",
    "\n",
    "def read_json(p: os.PathLike | str) -> Any:\n",
    "    p = Path(p)\n",
    "    return json.loads(p.read_text(encoding=\"utf-8\")) if p.exists() else None\n",
    "\n",
    "def write_json(p: os.PathLike | str, d: Any, pretty: bool = True) -> None:\n",
    "    p = Path(p)\n",
    "    p.parent.mkdir(parents=True, exist_ok=True)\n",
    "    if pretty:\n",
    "        s = json.dumps(d, indent=2, ensure_ascii=False)\n",
    "    else:\n",
    "        s = stable_json_dumps(d)\n",
    "    p.write_text(s, encoding=\"utf-8\")\n",
    "\n",
    "def append_jsonl(p: os.PathLike | str, record: Dict[str, Any]) -> None:\n",
    "    \"\"\"Append a JSON line to a .jsonl log.\"\"\"\n",
    "    p = Path(p)\n",
    "    p.parent.mkdir(parents=True, exist_ok=True)\n",
    "    with p.open(\"a\", encoding=\"utf-8\") as f:\n",
    "        f.write(stable_json_dumps(record) + \"\\n\")\n",
    "\n",
    "def has_file(p: os.PathLike | str) -> bool:\n",
    "    return Path(p).is_file()\n",
    "\n",
    "def stamp(p: os.PathLike | str, msg: str = \"checkpoint\") -> None:\n",
    "    write_text(p, f\"{msg}: {now_utc_iso()}\")\n",
    "\n",
    "def log_exc(label: str, e: Exception) -> None:\n",
    "    \"\"\"Capture structured error to logs/<label>_error.json.\"\"\"\n",
    "    Path(\"logs\").mkdir(parents=True, exist_ok=True)\n",
    "    write_json(\n",
    "        f\"logs/{_safe_label(label)}_error.json\",\n",
    "        {\n",
    "            \"type\": type(e).__name__,\n",
    "            \"msg\": str(e),\n",
    "            \"trace\": traceback.format_exc(),\n",
    "            \"ts\": now_utc_iso(),\n",
    "        },\n",
    "    )\n",
    "\n",
    "# ----------------------------------------------------------------------\n",
    "# Markdown sanitizer (Blueprint §3: Sanitizer; §6 Quality gates expect clean MD)\n",
    "# ----------------------------------------------------------------------\n",
    "\n",
    "def sanitize_md(t: str, *, ensure_final_newline: bool = True) -> str:\n",
    "    \"\"\"\n",
    "    Normalize MD text:\n",
    "      - Normalize newlines\n",
    "      - Strip a single leading/trailing fenced block wrapper if present\n",
    "      - Trim trailing spaces\n",
    "      - Collapse ≥3 consecutive blank lines to 2\n",
    "    \"\"\"\n",
    "    t = (t or \"\").replace(\"\\r\\n\", \"\\n\").replace(\"\\r\", \"\\n\").strip()\n",
    "\n",
    "    # Remove exactly one opening fence if it's the very first line\n",
    "    t = re.sub(r\"^```(?:\\w+)?\\n\", \"\", t, flags=re.I)\n",
    "\n",
    "    # Remove exactly one trailing fence if it's the very last line\n",
    "    t = re.sub(r\"\\n```$\", \"\", t)\n",
    "\n",
    "    # Trim trailing spaces on each line\n",
    "    t = \"\\n\".join([ln.rstrip() for ln in t.split(\"\\n\")])\n",
    "\n",
    "    # Collapse 3+ blank lines → 2\n",
    "    t = re.sub(r\"\\n{3,}\", \"\\n\\n\", t)\n",
    "\n",
    "    if ensure_final_newline and (not t.endswith(\"\\n\")):\n",
    "        t += \"\\n\"\n",
    "    return t.strip() if not ensure_final_newline else t\n",
    "\n",
    "def enforce_heading_policy(md: str, *, chapter_heading_fmt: str = \"## Chapter {number}: {title}\") -> str:\n",
    "    \"\"\"\n",
    "    Light enforcement for heading policy:\n",
    "      - Ensure chapter headings start with '## '\n",
    "      - Avoid accidental H1s; downshift '# ' → '## ' if it looks like a chapter heading\n",
    "    \"\"\"\n",
    "    lines = (md or \"\").splitlines()\n",
    "    out = []\n",
    "    for i, ln in enumerate(lines):\n",
    "        if re.match(r\"^#\\s+Chapter\\s+\\d+:\", ln.strip(), flags=re.I):\n",
    "            out.append(re.sub(r\"^#\\s+\", \"## \", ln))\n",
    "        else:\n",
    "            out.append(ln)\n",
    "    return \"\\n\".join(out)\n",
    "\n",
    "# ----------------------------------------------------------------------\n",
    "# Tokens & word counts (Blueprint §7: budget heuristics)\n",
    "# ----------------------------------------------------------------------\n",
    "\n",
    "CHARS_PER_TOKEN = _safe_int(os.getenv(\"CHARS_PER_TOKEN\", 4), 4)\n",
    "\n",
    "def count_words(t: str) -> int:\n",
    "    return len((t or \"\").split())\n",
    "\n",
    "def approx_tokens(t: str) -> int:\n",
    "    \"\"\"Heuristic: ~4 chars/token by default; overridable via CHARS_PER_TOKEN env.\"\"\"\n",
    "    t = t or \"\"\n",
    "    return max(1, int(len(t) / max(1, CHARS_PER_TOKEN)))\n",
    "\n",
    "# ----------------------------------------------------------------------\n",
    "# Cache keys (Blueprint §7 Determinism: prompt/version stamps + bible/knowledge hash)\n",
    "# ----------------------------------------------------------------------\n",
    "\n",
    "def make_cache_key(prefix: str, payload: Dict[str, Any]) -> str:\n",
    "    \"\"\"\n",
    "    Build a stable cache key:\n",
    "      sha1( prefix + \":\" + stable_json(payload) )\n",
    "    Expected payload keys for authoring stages include:\n",
    "      pv, kind, pack, ch, sec, oc_min, tw, kb_hash, bible_hash\n",
    "    \"\"\"\n",
    "    s = f\"{prefix}:{stable_json_dumps(payload)}\"\n",
    "    return sha1(s)\n",
    "\n",
    "# ----------------------------------------------------------------------\n",
    "# Cost tracking (Blueprint §7: reserve→reconcile envelopes; run/chap caps)\n",
    "# ----------------------------------------------------------------------\n",
    "\n",
    "class CostCapExceededException(Exception):\n",
    "    pass\n",
    "\n",
    "@dataclass\n",
    "class CostEvent:\n",
    "    t: str\n",
    "    type: str                      # \"reserve\" | \"reconcile\"\n",
    "    label: str\n",
    "    est: Optional[float] = None\n",
    "    act: Optional[float] = None\n",
    "    delta: Optional[float] = None\n",
    "    model: Optional[str] = None\n",
    "    ptok: Optional[int] = None\n",
    "    ctok: Optional[int] = None\n",
    "\n",
    "class CostTracker:\n",
    "    \"\"\"\n",
    "    Reserve → Reconcile accounting with optional per-chapter envelopes.\n",
    "    - price table can be overridden via env: PRICE_<MODEL_UPPER>_IN / _OUT (USD per 1K tokens)\n",
    "    - .reserve(label, amount)\n",
    "    - .reconcile(label, est_amount, actual_amount)\n",
    "    - .estimate_cost(model, prompt_tokens, completion_tokens)\n",
    "    - .child_envelope(name, cap)  (per-chapter budget guard)\n",
    "    Persists to logs/cost.json & logs/agent_calls.jsonl companions when used by caller.\n",
    "    \"\"\"\n",
    "\n",
    "    # Baseline price table (conservative defaults; override via env if needed)\n",
    "    PRICES = {\n",
    "        \"gpt-5-thinking\":    {\"in\": 0.00150, \"out\": 0.0120},\n",
    "        \"gpt-5\":             {\"in\": 0.00125, \"out\": 0.0100},\n",
    "        \"gpt-5-mini\":        {\"in\": 0.00025, \"out\": 0.0020},\n",
    "        \"gpt-4o\":            {\"in\": 0.00050, \"out\": 0.0015},\n",
    "        \"gpt-4o-mini\":       {\"in\": 0.00015, \"out\": 0.0006},\n",
    "        \"gpt-4-turbo\":       {\"in\": 0.01000, \"out\": 0.0300},\n",
    "        \"gpt-3.5-turbo\":     {\"in\": 0.00050, \"out\": 0.0015},\n",
    "        \"default\":           {\"in\": 0.00100, \"out\": 0.0030},\n",
    "    }\n",
    "\n",
    "    # env overrides (e.g., PRICE_GPT_4O_IN=0.00045 PRICE_GPT_4O_OUT=0.0013)\n",
    "    for key in list(PRICES.keys()):\n",
    "        if key == \"default\":\n",
    "            continue\n",
    "        env_key = key.upper().replace(\"-\", \"_\").replace(\".\", \"_\")\n",
    "        pin = os.getenv(f\"PRICE_{env_key}_IN\")\n",
    "        pout = os.getenv(f\"PRICE_{env_key}_OUT\")\n",
    "        try:\n",
    "            if pin is not None and pout is not None:\n",
    "                PRICES[key] = {\"in\": float(pin), \"out\": float(pout)}\n",
    "        except Exception:\n",
    "            pass\n",
    "\n",
    "    def __init__(self, cap_usd: float, *, autosave: bool = True, log_path: str = \"logs/cost.json\"):\n",
    "        self.cap = float(cap_usd)\n",
    "        self.spent = 0.0\n",
    "        self.events: list[CostEvent] = []\n",
    "        self.autosave = autosave\n",
    "        self.log_path = log_path\n",
    "        self._pending: Dict[str, float] = {}  # label -> last_estimate reserved\n",
    "\n",
    "        # ensure logs dir\n",
    "        Path(self.log_path).parent.mkdir(parents=True, exist_ok=True)\n",
    "\n",
    "    # ---- pricing helpers ----\n",
    "    def _price_record(self, model: str) -> Dict[str, float]:\n",
    "        if model in self.PRICES:\n",
    "            return self.PRICES[model]\n",
    "        # longest-prefix match\n",
    "        for k in sorted(self.PRICES, key=len, reverse=True):\n",
    "            if k != \"default\" and model.startswith(k):\n",
    "                return self.PRICES[k]\n",
    "        return self.PRICES[\"default\"]\n",
    "\n",
    "    def estimate_cost(self, model: str, prompt_tokens: int, completion_tokens: int) -> float:\n",
    "        p = self._price_record(model)\n",
    "        return (prompt_tokens / 1000.0) * p[\"in\"] + (completion_tokens / 1000.0) * p[\"out\"]\n",
    "\n",
    "    # ---- cap checks ----\n",
    "    def _can(self, amount: float) -> bool:\n",
    "        return (self.spent + float(amount)) <= (self.cap + 1e-9)\n",
    "\n",
    "    # ---- main API ----\n",
    "    def reserve(self, label: str, amount_usd: float) -> None:\n",
    "        \"\"\"Reserve budget before a call; raises if it would exceed run cap.\"\"\"\n",
    "        amt = float(amount_usd)\n",
    "        if not self._can(amt):\n",
    "            raise CostCapExceededException(\n",
    "                f\"Cap hit before {label}: need {amt:.4f}, left {(self.cap - self.spent):.4f}\"\n",
    "            )\n",
    "        self.spent += amt\n",
    "        self._pending[label] = amt\n",
    "        ev = CostEvent(t=now_utc_iso(), type=\"reserve\", label=label, est=round(amt, 6))\n",
    "        self.events.append(ev)\n",
    "        if self.autosave:\n",
    "            self.save()\n",
    "\n",
    "    # alias for backward compatibility\n",
    "    spend = reserve\n",
    "\n",
    "    def reconcile(self, label: str, actual_usd: float) -> None:\n",
    "        \"\"\"Reconcile actual cost against last reserve for this label; only positive deltas increase spend.\"\"\"\n",
    "        est = float(self._pending.get(label, 0.0))\n",
    "        act = float(actual_usd)\n",
    "        delta = max(0.0, act - est)\n",
    "        if delta > 0 and not self._can(delta):\n",
    "            raise CostCapExceededException(f\"Cap hit reconciling {label} +{delta:.4f}\")\n",
    "        self.spent += delta\n",
    "        ev = CostEvent(\n",
    "            t=now_utc_iso(), type=\"reconcile\", label=label,\n",
    "            est=round(est, 6), act=round(act, 6), delta=round(delta, 6)\n",
    "        )\n",
    "        self.events.append(ev)\n",
    "        # clear pending for label (one-shot)\n",
    "        self._pending.pop(label, None)\n",
    "        if self.autosave:\n",
    "            self.save()\n",
    "\n",
    "    # alias for backward compatibility\n",
    "    recon = reconcile\n",
    "\n",
    "    # ---- envelopes ----\n",
    "    def child_envelope(self, name: str, cap_usd: float) -> \"ChapterEnvelope\":\n",
    "        return ChapterEnvelope(parent=self, name=name, cap_usd=cap_usd)\n",
    "\n",
    "    # ---- persistence ----\n",
    "    def summary(self) -> Dict[str, Any]:\n",
    "        return {\n",
    "            \"total_spent_usd\": round(self.spent, 6),\n",
    "            \"run_cap_usd\": round(self.cap, 6),\n",
    "            \"remaining_usd\": round(max(0, self.cap - self.spent), 6),\n",
    "            \"events\": len(self.events),\n",
    "            \"ts\": now_utc_iso(),\n",
    "        }\n",
    "\n",
    "    def to_dict(self) -> Dict[str, Any]:\n",
    "        return {\n",
    "            \"summary\": self.summary(),\n",
    "            \"events\": [asdict(e) for e in self.events],\n",
    "        }\n",
    "\n",
    "    def save(self) -> None:\n",
    "        write_json(self.log_path, self.to_dict(), pretty=True)\n",
    "\n",
    "class ChapterEnvelope:\n",
    "    \"\"\"\n",
    "    Per-chapter/per-stage budget guard that also respects the parent run cap.\n",
    "    Usage:\n",
    "        ch = tracker.child_envelope(\"ch12\", cap_usd=0.25)\n",
    "        ch.reserve(\"author_block\", 0.08)\n",
    "        # ... do call ...\n",
    "        ch.reconcile(\"author_block\", actual_usd=0.07)\n",
    "    \"\"\"\n",
    "    def __init__(self, parent: CostTracker, name: str, cap_usd: float):\n",
    "        self.parent = parent\n",
    "        self.name = _safe_label(name)\n",
    "        self.cap = float(cap_usd)\n",
    "        self.spent = 0.0\n",
    "\n",
    "    def _can(self, amount: float) -> bool:\n",
    "        return (self.spent + float(amount)) <= (self.cap + 1e-9)\n",
    "\n",
    "    def reserve(self, label: str, amount_usd: float) -> None:\n",
    "        if not self._can(amount_usd):\n",
    "            raise CostCapExceededException(\n",
    "                f\"[{self.name}] envelope hit before {label}: need {amount_usd:.4f}, left {(self.cap - self.spent):.4f}\"\n",
    "            )\n",
    "        self.spent += float(amount_usd)\n",
    "        # Delegate to parent run-level tracker as well\n",
    "        self.parent.reserve(f\"{self.name}:{label}\", amount_usd)\n",
    "\n",
    "    spend = reserve\n",
    "\n",
    "    def reconcile(self, label: str, actual_usd: float) -> None:\n",
    "        # Parent handles delta/cap; we track envelope-only delta for accounting symmetry\n",
    "        est = float(self.parent._pending.get(f\"{self.name}:{label}\", 0.0))\n",
    "        act = float(actual_usd)\n",
    "        delta = max(0.0, act - est)\n",
    "        if delta > 0 and not self._can(delta):\n",
    "            raise CostCapExceededException(\n",
    "                f\"[{self.name}] envelope hit on reconcile {label} +{delta:.4f}\"\n",
    "            )\n",
    "        self.spent += delta\n",
    "        self.parent.reconcile(f\"{self.name}:{label}\", actual_usd)\n",
    "\n",
    "    recon = reconcile\n",
    "\n",
    "# ----------------------------------------------------------------------\n",
    "# Agent logging & run manifest (Blueprint §8)\n",
    "# ----------------------------------------------------------------------\n",
    "\n",
    "def log_agent_call(payload: Dict[str, Any], path: str = \"logs/agent_calls.jsonl\") -> None:\n",
    "    \"\"\"Append an agent call record with usage & cache flags.\"\"\"\n",
    "    record = dict(payload)\n",
    "    record.setdefault(\"ts\", now_utc_iso())\n",
    "    append_jsonl(path, record)\n",
    "\n",
    "def update_run_manifest(delta: Dict[str, Any], path: str = \"logs/run_manifest.json\") -> Dict[str, Any]:\n",
    "    \"\"\"\n",
    "    Merge-in updates to run manifest (title, kind/pack, chapters done, cost, gate pass rates).\n",
    "    Call iteratively; we keep previous keys unless overwritten.\n",
    "    \"\"\"\n",
    "    cur = read_json(path) or {}\n",
    "    # Shallow merge at top-level; caller can manage nested dicts explicitly\n",
    "    cur.update(delta or {})\n",
    "    cur.setdefault(\"updated_at\", now_utc_iso())\n",
    "    cur[\"updated_at\"] = now_utc_iso()\n",
    "    write_json(path, cur, pretty=True)\n",
    "    return cur\n",
    "\n",
    "# ----------------------------------------------------------------------\n",
    "# Length targeting (Blueprint §5 Orchestration: compute chapter/section targets)\n",
    "# ----------------------------------------------------------------------\n",
    "\n",
    "def compute_chapter_word_targets(\n",
    "    book_spec: Dict[str, Any],\n",
    "    cfg: Dict[str, Any],\n",
    "    sections_count: Optional[int] = None,\n",
    "    ch_num: Optional[int] = None,\n",
    ") -> Tuple[int, int, int, int]:\n",
    "    \"\"\"\n",
    "    Derive per-chapter and per-section word targets.\n",
    "\n",
    "    Inputs:\n",
    "      - book_spec[\"target_length_words\"], book_spec[\"chapters\"]\n",
    "      - optional book_spec[\"chapter_target_words\"], [\"chapter_weights\"]\n",
    "      - chapter_template.section_weights (if present)\n",
    "      - cfg[\"CHAPTER_TOLERANCE_PCT\"], optional CHAPTER_MIN_WORDS/CHAPTER_MAX_WORDS\n",
    "      - cfg[\"AUTHOR_SECTION_TARGET_WORDS\"] (override per-section)\n",
    "\n",
    "    Returns:\n",
    "      (per_chapter, min_words, max_words, per_section_target)\n",
    "    \"\"\"\n",
    "    # Total words and chapter count\n",
    "    total = _safe_int(book_spec.get(\"target_length_words\", 95_000), 95_000)\n",
    "    n_ch = max(1, _safe_int(book_spec.get(\"chapters\", 18), 18))\n",
    "\n",
    "    # Base per-chapter target (explicit override wins; else total/n)\n",
    "    per = _safe_int(book_spec.get(\"chapter_target_words\", total // n_ch), total // n_ch)\n",
    "\n",
    "    # Chapter-specific weight\n",
    "    weights = book_spec.get(\"chapter_weights\") or []\n",
    "    if ch_num and 1 <= ch_num <= len(weights):\n",
    "        try:\n",
    "            w = float(weights[ch_num - 1] or 1.0)\n",
    "            per = max(600, int(per * w))\n",
    "        except Exception:\n",
    "            pass\n",
    "\n",
    "    # Tolerance band\n",
    "    tol = float(cfg.get(\"CHAPTER_TOLERANCE_PCT\", 0.18))\n",
    "    min_w = int(per * (1 - tol))\n",
    "    max_w = int(per * (1 + tol))\n",
    "\n",
    "    # Explicit min/max overrides from cfg\n",
    "    if \"CHAPTER_MIN_WORDS\" in cfg:\n",
    "        min_w = _safe_int(cfg[\"CHAPTER_MIN_WORDS\"], min_w)\n",
    "    if \"CHAPTER_MAX_WORDS\" in cfg:\n",
    "        max_w = _safe_int(cfg[\"CHAPTER_MAX_WORDS\"], max_w)\n",
    "\n",
    "    # Sections\n",
    "    tmpl = (book_spec.get(\"chapter_template\") or {})\n",
    "    sec_ws = tmpl.get(\"section_weights\") or []\n",
    "    if sections_count is None:\n",
    "        sections_count = len(sec_ws) if sec_ws else len(tmpl.get(\"sections\") or []) or 5\n",
    "\n",
    "    if sec_ws and len(sec_ws) == sections_count:\n",
    "        ssum = sum(max(0.01, float(x)) for x in sec_ws)\n",
    "        # nominal target for the *largest* section weight; author pass can distribute within\n",
    "        per_section_nominal = max(400, int(per * (max(sec_ws) / ssum)))\n",
    "    else:\n",
    "        per_section_nominal = max(600, per // max(1, sections_count))\n",
    "\n",
    "    # Optional explicit override\n",
    "    per_section = _safe_int(cfg.get(\"AUTHOR_SECTION_TARGET_WORDS\", per_section_nominal), per_section_nominal)\n",
    "\n",
    "    return per, min_w, max_w, per_section\n",
    "\n",
    "print(\"[utils] ready — IO/sanitize/cache/cost/targets initialized\")"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": 7,
   "metadata": {},
   "outputs": [
    {
     "name": "stdout",
     "output_type": "stream",
     "text": [
      "Agents ready (Cell 4)\n"
     ]
    }
   ],
   "source": [
    "# Cell 4 — Agents Wiring (adds author_tighten & gate_check stages; deterministic caching; selective research; budget-aware)\n",
    "\n",
    "from __future__ import annotations\n",
    "\n",
    "import os, re, json, time\n",
    "from pathlib import Path\n",
    "from typing import Any, Dict, Optional, Tuple\n",
    "\n",
    "# Assumes from previous cells:\n",
    "# - client (OpenAI), REQUEST_OPTS (dict), now_utc_iso, approx_tokens, sha1, stable_json_dumps,\n",
    "#   make_cache_key, sanitize_md, write_json, write_text, read_json, append_jsonl, log_agent_call,\n",
    "#   CostTracker, ChapterEnvelope, CONFIG\n",
    "#\n",
    "# Available in CONFIG:\n",
    "#   CONFIG[\"book_spec\"], CONFIG[\"pipeline\"], CONFIG[\"gates\"], CONFIG[\"paths\"]\n",
    "\n",
    "BOOK = CONFIG[\"book_spec\"]\n",
    "PL = CONFIG[\"pipeline\"]\n",
    "GATES = CONFIG[\"gates\"]\n",
    "PATHS = CONFIG[\"paths\"]\n",
    "\n",
    "# -------------------------- Dynamic system prompts (aligned with v2.0) --------------------------\n",
    "\n",
    "def _fmt_gate_state(d: Dict[str, bool]) -> str:\n",
    "    if not d:\n",
    "        return \"(none)\"\n",
    "    on = [k for k, v in d.items() if v]\n",
    "    off = [k for k, v in d.items() if not v]\n",
    "    return f\"ON: {', '.join(on) if on else '—'} | OFF: {', '.join(off) if off else '—'}\"\n",
    "\n",
    "def pmrouter_sys_prompt() -> str:\n",
    "    return f\"\"\"\n",
    "You are PMRouter — the orchestration, budget, and logging layer for an agentic book factory.\n",
    "\n",
    "NORTH-STAR\n",
    "- Deterministic, resumable, low-cost pipeline to generate professional-quality books (fiction & nonfiction).\n",
    "\n",
    "GUARDRAILS\n",
    "- Cost envelopes: reserve → reconcile. Respect run cap ${PL.get('RUN_COST_CAP_USD', 3.00):.2f} and per-chapter caps.\n",
    "- Determinism: cache by stage prefix + PROMPT_VERSION + bible/knowledge hash; reuse cached artifacts.\n",
    "- Persist everything: outline/drafts/edits/claims/notes to the canonical paths in CONFIG.\n",
    "\n",
    "WORKFLOW\n",
    "1) Outline → 2) Per-Chapter loop (Block → Gate → Tighten → Editor → Selective Research → Merge) → 3) Weave → 4) Export.\n",
    "\n",
    "SELECTIVE RESEARCH\n",
    "- Only resolve editor-tagged [n] or flagged claims; produce JSON summaries/citations; do not inject prose directly.\n",
    "\n",
    "QUALITY GATES (toggle per CONFIG)\n",
    "- Fiction: {_fmt_gate_state(PL.get('GATES_FICTION', {}))}\n",
    "- Nonfiction: {_fmt_gate_state(PL.get('GATES_NONFICTION', {}))}\n",
    "\n",
    "LOGGING\n",
    "- Append a jsonl record per call with: agent, model, label, cache_hit, usage, est_cost, actual_cost, duration.\n",
    "- Retry once on transient failure, then continue non-blocking and log.\n",
    "\n",
    "You enforce STOP if caps are exceeded and persist partials for resumption.\n",
    "\"\"\".strip()\n",
    "\n",
    "def author_sys_prompt() -> str:\n",
    "    voice = (BOOK.get(\"style_guide\") or {}).get(\"voice\", \"\")\n",
    "    formatting = (BOOK.get(\"style_guide\") or {}).get(\"formatting\", \"\")\n",
    "    kind = BOOK.get(\"kind\")\n",
    "    pack = BOOK.get(\"genre_pack\") or BOOK.get(\"structure_pack\")\n",
    "    return f\"\"\"\n",
    "You are AuthorAgent.\n",
    "\n",
    "TASK\n",
    "- Draft scenes (fiction) or sections (nonfiction) from outline inputs with minimal exposition, high clarity.\n",
    "- Respect style & tone. Use [n] markers where external support is required (facts, stats, quotes).\n",
    "\n",
    "STYLE\n",
    "- Kind: {kind}; Pack: {pack}; Voice: {voice}; Formatting: {formatting}\n",
    "- Keep paragraphs short and cinematic (fiction) or argumentatively crisp (nonfiction).\n",
    "- Do NOT fabricate citations or sources; leave [n] for ResearchAgent.\n",
    "\n",
    "OUTPUT\n",
    "- Clean Markdown only. No YAML frontmatter. No triple backticks.\n",
    "- For the last section in a chapter:\n",
    "  - Fiction → end with a hook/cliffhanger if enabled.\n",
    "  - Nonfiction → end with a concrete takeaway/bridge.\n",
    "\n",
    "DETERMINISM\n",
    "- Honor provided word targets; stay within tolerance.\n",
    "- Avoid randomness beyond temperature; no meta commentary.\n",
    "\n",
    "NOTE\n",
    "- You may also be asked to perform a LIGHT 'TIGHTEN' pass on an already-drafted chapter. In that case:\n",
    "  - Improve cadence/clarity; remove redundancy; preserve facts/continuity; do not change the chapter title/number.\n",
    "\"\"\".strip()\n",
    "\n",
    "def editor_sys_prompt() -> str:\n",
    "    cit = BOOK.get(\"citation_style\", \"none\")\n",
    "    return f\"\"\"\n",
    "You are EditorAgent.\n",
    "\n",
    "TASK\n",
    "- Polish AuthorAgent text: enforce tone/style/heading policy, remove redundancy, improve flow.\n",
    "- Insert [n] markers wherever claims require support.\n",
    "- Emit three artifacts:\n",
    "  1) <DRAFT_EDITED_MD>…</DRAFT_EDITED_MD> — sanitized Markdown\n",
    "  2) <CLAIMS_REPORT_JSON>{{\"claims\":[{{\"id\":1,\"marker\":\"[1]\",\"text\":\"…\",\"category\":\"tech|ops|science|history|quote\",\"confidence\":\"low|med|high\"}}]]}}</CLAIMS_REPORT_JSON>\n",
    "  3) <CONTINUITY_OR_ARGUMENT_NOTES_MD>…</CONTINUITY_OR_ARGUMENT_NOTES_MD>\n",
    "\n",
    "QUALITY GATES\n",
    "- Fiction gates (when enabled): value_shift per scene; chapter ending hook; voice enforcement; cliché & redundancy sweeps; antagonist competence.\n",
    "- Nonfiction gates (when enabled): claim-evidence mapping; logical flow; citation hygiene ({cit}); redundancy & terminology consistency.\n",
    "\n",
    "RULES\n",
    "- Zero uncited direct quotes. Use [n] markers instead.\n",
    "- Preserve chapter/section structure; do not re-outline.\n",
    "- Keep Markdown clean (no code fences unless content is code).\n",
    "\n",
    "OUTPUT\n",
    "- Wrap exactly as specified with the three tags. Nothing else.\n",
    "\n",
    "ADDITIONAL STAGE\n",
    "- You may also be queried for small JSON 'gate_check' scorers; respond succinctly with strict JSON only when requested.\n",
    "\"\"\".strip()\n",
    "\n",
    "def research_sys_prompt() -> str:\n",
    "    style = BOOK.get(\"citation_style\", \"none\")\n",
    "    return f\"\"\"\n",
    "You are ResearchAgent.\n",
    "\n",
    "TASK\n",
    "- Resolve [n] markers or claim items with concise, defensible summaries (max 3 bullets each).\n",
    "- Provide minimal citation metadata sufficient for later formatting (style: {style.upper()} if applicable).\n",
    "\n",
    "SOURCES\n",
    "- Prefer reputable, open sources: peer-reviewed articles, official stats, filings, government/NGO reports.\n",
    "- Disallow: leaked/classified materials, unsafe or illegal content.\n",
    "\n",
    "FALLBACK\n",
    "- If no reliable source is found, DO NOT guess. Instead return:\n",
    "  - research_question: the refined question\n",
    "  - recommended_sources: where to look next (types/databases)\n",
    "\n",
    "OUTPUT (strict JSON list of items)\n",
    "[\n",
    "  {{\"claim_id\": 1, \"marker\":\"[1]\", \"summary\": \"…\", \"citations\": [{{\"title\":\"…\",\"url\":\"…\",\"year\":2022,\"note\":\"…\"}}]}}\n",
    "]\n",
    "No prose, no Markdown, JSON only.\n",
    "\"\"\".strip()\n",
    "\n",
    "# -------------------------- Low-level chat (OpenAI >=1.x; robust & text-first) --------------------------\n",
    "\n",
    "def _extract_text_from_msg(msg) -> str:\n",
    "    # Prefer parsed JSON if present\n",
    "    parsed = getattr(msg, \"parsed\", None)\n",
    "    if parsed is not None:\n",
    "        if isinstance(parsed, (dict, list)):\n",
    "            try:\n",
    "                return json.dumps(parsed, ensure_ascii=False)\n",
    "            except Exception:\n",
    "                pass\n",
    "        if isinstance(parsed, str):\n",
    "            return parsed.strip()\n",
    "\n",
    "    # Normal .content\n",
    "    content = getattr(msg, \"content\", None)\n",
    "    if isinstance(content, str) and content.strip():\n",
    "        return content.strip()\n",
    "    if isinstance(content, (list, tuple)) and content:\n",
    "        p0 = content[0]\n",
    "        if isinstance(p0, dict):\n",
    "            for cand in (\"text\", \"content\", \"value\"):\n",
    "                v = p0.get(cand)\n",
    "                if isinstance(v, str) and v.strip():\n",
    "                    return v.strip()\n",
    "                if isinstance(v, dict) and isinstance(v.get(\"value\"), str):\n",
    "                    return v[\"value\"].strip()\n",
    "        else:\n",
    "            for cand in (\"text\", \"content\", \"value\"):\n",
    "                v = getattr(p0, cand, None)\n",
    "                if isinstance(v, str) and v.strip():\n",
    "                    return v.strip()\n",
    "                if isinstance(v, dict) and isinstance(v.get(\"value\"), str):\n",
    "                    return v[\"value\"].strip()\n",
    "    return \"\"\n",
    "\n",
    "def _extract_text_from_response(r) -> str:\n",
    "    t = getattr(r, \"output_text\", None)\n",
    "    if isinstance(t, str) and t.strip():\n",
    "        return t.strip()\n",
    "\n",
    "    # chat.completions\n",
    "    try:\n",
    "        choices = getattr(r, \"choices\", None)\n",
    "        if choices and len(choices) > 0:\n",
    "            msg = getattr(choices[0], \"message\", None)\n",
    "            if msg is not None:\n",
    "                s = _extract_text_from_msg(msg)\n",
    "                if s:\n",
    "                    return s\n",
    "            if isinstance(choices[0], dict):\n",
    "                maybe = choices[0].get(\"message\", {}).get(\"content\")\n",
    "                if isinstance(maybe, str) and maybe.strip():\n",
    "                    return maybe.strip()\n",
    "    except Exception:\n",
    "        pass\n",
    "\n",
    "    # responses\n",
    "    for attr in (\"output\", \"outputs\", \"content\"):\n",
    "        maybe = getattr(r, attr, None)\n",
    "        if isinstance(maybe, str) and maybe.strip():\n",
    "            return maybe.strip()\n",
    "        if isinstance(maybe, (list, tuple)) and maybe:\n",
    "            p0 = maybe[0]\n",
    "            if isinstance(p0, dict):\n",
    "                v = p0.get(\"text\") or p0.get(\"content\") or p0.get(\"value\")\n",
    "                if isinstance(v, str) and v.strip():\n",
    "                    return v.strip()\n",
    "    return \"\"\n",
    "\n",
    "def chat(model: str, sysm: str, userm: str, temp: float = 0.2, max_t: int = 800, *, force_json: bool = False) -> Tuple[str, Dict[str, Any]]:\n",
    "    if not model:\n",
    "        raise ValueError(\"Model ID is required\")\n",
    "\n",
    "    # Model output caps (best-effort)\n",
    "    caps = {\n",
    "        \"gpt-4o\":      {\"out\": 16384},\n",
    "        \"gpt-4o-mini\": {\"out\": 8192},\n",
    "    }\n",
    "    m_lower = model.lower()\n",
    "    out_cap = 16384\n",
    "    if \"gpt-4o-mini\" in m_lower:\n",
    "        out_cap = caps[\"gpt-4o-mini\"][\"out\"]\n",
    "    elif \"gpt-4o\" in m_lower:\n",
    "        out_cap = caps[\"gpt-4o\"][\"out\"]\n",
    "\n",
    "    max_completion_tokens = min(int(max_t or 0), out_cap) if max_t else None\n",
    "\n",
    "    base_messages = [\n",
    "        {\"role\": \"system\", \"content\": sysm},\n",
    "        {\"role\": \"user\", \"content\": \"STRICT JSON OUTPUT ONLY.\\n\" + userm if force_json else userm},\n",
    "    ]\n",
    "\n",
    "    # 1) Try chat.completions\n",
    "    kwargs = {}\n",
    "    if max_completion_tokens:\n",
    "        kwargs[\"max_completion_tokens\"] = max_completion_tokens\n",
    "    if temp is not None:\n",
    "        kwargs[\"temperature\"] = float(temp)\n",
    "    kwargs.setdefault(\"modalities\", [\"text\"])\n",
    "    try:\n",
    "        r = client.chat.completions.create(model=model, messages=base_messages, **kwargs, **(REQUEST_OPTS or {}))\n",
    "        text = _extract_text_from_response(r)\n",
    "        usage = getattr(r, \"usage\", None)\n",
    "        usage_dict = {\n",
    "            \"prompt_tokens\": getattr(usage, \"prompt_tokens\", None) or getattr(usage, \"input_tokens\", None),\n",
    "            \"completion_tokens\": getattr(usage, \"completion_tokens\", None) or getattr(usage, \"output_tokens\", None),\n",
    "            \"via\": \"chat.completions\",\n",
    "        }\n",
    "        if force_json and not (text or \"\").strip().startswith((\"{\", \"[\")):\n",
    "            # fallback to Responses if JSON not honored\n",
    "            raise RuntimeError(\"non_json_output\")\n",
    "        return text, usage_dict\n",
    "    except Exception:\n",
    "        pass\n",
    "\n",
    "    # 2) Fallback: responses API (text-only)\n",
    "    kwargs = {}\n",
    "    if max_completion_tokens:\n",
    "        kwargs[\"max_output_tokens\"] = max_completion_tokens\n",
    "    r = client.responses.create(model=model, input=base_messages, **kwargs, **(REQUEST_OPTS or {}))\n",
    "    text = _extract_text_from_response(r)\n",
    "    usage = getattr(r, \"usage\", None)\n",
    "    usage_dict = {\n",
    "        \"prompt_tokens\": getattr(usage, \"input_tokens\", None) or getattr(usage, \"prompt_tokens\", None),\n",
    "        \"completion_tokens\": getattr(usage, \"output_tokens\", None) or getattr(usage, \"completion_tokens\", None),\n",
    "        \"via\": \"responses\",\n",
    "    }\n",
    "    if force_json and not (text or \"\").strip().startswith((\"{\", \"[\")):\n",
    "        raise RuntimeError(\"Model did not return JSON.\")\n",
    "    return text, usage_dict\n",
    "\n",
    "# -------------------------- Agent class (cache & budget aware) --------------------------\n",
    "\n",
    "def _bible_or_kb_hash() -> str:\n",
    "    \"\"\"\n",
    "    Returns a content hash for the Story Bible (fiction) or Knowledge Base (nonfiction),\n",
    "    used to invalidate caches when continuity/knowledge changes.\n",
    "    \"\"\"\n",
    "    p = Path(PATHS[\"story_bible\"]) if BOOK.get(\"kind\") == \"fiction\" else Path(PATHS[\"knowledge_base\"])\n",
    "    if p.exists():\n",
    "        try:\n",
    "            return sha1(p.read_text(encoding=\"utf-8\"))\n",
    "        except Exception:\n",
    "            return \"nohash\"\n",
    "    return \"nohash\"\n",
    "\n",
    "def _prefix_for(stage: str) -> str:\n",
    "    \"\"\"\n",
    "    Stable, short cache key prefixes per stage. Allows custom overrides via PL['CACHE_PREFIXES'].\n",
    "    \"\"\"\n",
    "    default = {\n",
    "        \"outline\":        \"ol\",\n",
    "        \"author_block\":   \"ab\",\n",
    "        \"author_tighten\": \"at\",\n",
    "        \"editor\":         \"ed\",\n",
    "        \"research\":       \"rs\",\n",
    "        \"gate_check\":     \"gc\",\n",
    "    }\n",
    "    return PL.get(\"CACHE_PREFIXES\", {}).get(stage, default.get(stage, stage[:2]))\n",
    "\n",
    "class Agent:\n",
    "    \"\"\"\n",
    "    Thin LLM wrapper with:\n",
    "    - deterministic caching keyed by stage + prompt-version + bible/KB hash + payload\n",
    "    - reserve→reconcile cost accounting via CostTracker / ChapterEnvelope\n",
    "    - robust text extraction & optional strict-JSON mode\n",
    "\n",
    "    Known stages: \"outline\", \"author_block\", \"author_tighten\", \"editor\", \"research\", \"gate_check\"\n",
    "    \"\"\"\n",
    "    def __init__(self, name: str, sysm_fn, model: str, temp: float, max_t: int, cache_dir: str, tracker: Optional[CostTracker]):\n",
    "        self.name = name\n",
    "        self._sysm_fn = sysm_fn  # callable producing dynamic system prompt\n",
    "        self.model = model\n",
    "        self.temp = temp\n",
    "        self.max_t = max_t\n",
    "        self.cache = Path(cache_dir)\n",
    "        self.cache.mkdir(parents=True, exist_ok=True)\n",
    "        self.tracker = tracker\n",
    "\n",
    "    @property\n",
    "    def sysm(self) -> str:\n",
    "        return self._sysm_fn()\n",
    "\n",
    "    def run(\n",
    "        self,\n",
    "        label: str,\n",
    "        prompt: str,\n",
    "        *,\n",
    "        stage: str,                      # \"outline\" | \"author_block\" | \"author_tighten\" | \"editor\" | \"research\" | \"gate_check\"\n",
    "        cache_payload: Optional[Dict[str, Any]] = None,\n",
    "        cache_key: Optional[str] = None,\n",
    "        max_t: Optional[int] = None,\n",
    "        force_json: bool = False,\n",
    "        envelope: Optional[ChapterEnvelope] = None,\n",
    "    ) -> Dict[str, Any]:\n",
    "        \"\"\"\n",
    "        Execute a call with deterministic caching and reserve→reconcile accounting.\n",
    "        \"\"\"\n",
    "        effective_max_t = int(max_t if max_t is not None else self.max_t)\n",
    "\n",
    "        # ---------------- Cache key (Blueprint §7) ----------------\n",
    "        if cache_key is None:\n",
    "            pv_map = PL.get(\"PROMPT_VERSIONS\", {})\n",
    "            if   \"author\"   in stage: pv = pv_map.get(\"author\")\n",
    "            elif \"editor\"   in stage: pv = pv_map.get(\"editor\")\n",
    "            elif \"research\" in stage: pv = pv_map.get(\"research\")\n",
    "            else:                     pv = pv_map.get(\"outline\")\n",
    "\n",
    "            payload = {\n",
    "                \"pv\": pv,\n",
    "                \"kind\": BOOK.get(\"kind\"),\n",
    "                \"pack\": BOOK.get(\"genre_pack\") or BOOK.get(\"structure_pack\"),\n",
    "                # Include whichever reference hash applies (fiction vs nonfiction)\n",
    "                \"bible_hash\":   _bible_or_kb_hash() if BOOK.get(\"kind\") == \"fiction\"    else None,\n",
    "                \"kb_hash\":      _bible_or_kb_hash() if BOOK.get(\"kind\") == \"nonfiction\" else None,\n",
    "                \"stage\": stage,\n",
    "            }\n",
    "            if cache_payload:\n",
    "                payload.update(cache_payload)\n",
    "            cache_key = make_cache_key(_prefix_for(stage), payload)\n",
    "\n",
    "        safe = re.sub(r\"[^A-Za-z0-9._-]+\", \"_\", label or \"step\")\n",
    "        cpath = self.cache / f\"{safe}_{cache_key}.json\"\n",
    "\n",
    "        # ---------------- Begin log ----------------\n",
    "        begin_rec = {\n",
    "            \"t\": now_utc_iso(),\n",
    "            \"agent\": self.name,\n",
    "            \"stage\": stage,\n",
    "            \"model\": self.model,\n",
    "            \"label\": label,\n",
    "            \"event\": \"begin\",\n",
    "            \"cache_key\": cache_key,\n",
    "            \"max_t\": effective_max_t,\n",
    "            \"force_json\": bool(force_json),\n",
    "        }\n",
    "        log_agent_call(begin_rec)\n",
    "\n",
    "        # ---------------- Cache read ----------------\n",
    "        if cpath.exists():\n",
    "            try:\n",
    "                d = json.loads(cpath.read_text(encoding=\"utf-8\"))\n",
    "                cached_txt = (d.get(\"text\") or \"\").strip()\n",
    "                if cached_txt:\n",
    "                    log_agent_call({\n",
    "                        \"t\": now_utc_iso(), \"agent\": self.name, \"stage\": stage, \"model\": self.model,\n",
    "                        \"label\": label, \"event\": \"cache_hit\", \"cache_path\": cpath.as_posix()\n",
    "                    })\n",
    "                    return {\"text\": cached_txt, \"cached\": True, \"usage\": d.get(\"usage\"), \"est_cost\": 0.0}\n",
    "                # purge empty\n",
    "                cpath.unlink(missing_ok=True)\n",
    "                log_agent_call({\n",
    "                    \"t\": now_utc_iso(), \"agent\": self.name, \"stage\": stage, \"model\": self.model,\n",
    "                    \"label\": label, \"event\": \"cache_purged_empty\", \"cache_path\": cpath.as_posix()\n",
    "                })\n",
    "            except Exception:\n",
    "                log_agent_call({\n",
    "                    \"t\": now_utc_iso(), \"agent\": self.name, \"stage\": stage, \"model\": self.model,\n",
    "                    \"label\": label, \"event\": \"cache_corrupt\", \"cache_path\": cpath.as_posix()\n",
    "                })\n",
    "\n",
    "        # ---------------- Reserve cost ----------------\n",
    "        pt = approx_tokens(self.sysm) + approx_tokens(prompt)\n",
    "        ct_budget = int(effective_max_t * 0.9)\n",
    "        est = self.tracker.estimate_cost(self.model, pt, ct_budget) if self.tracker else 0.0\n",
    "\n",
    "        if envelope:\n",
    "            envelope.reserve(f\"{label}[reserve]\", est)\n",
    "        elif self.tracker:\n",
    "            self.tracker.reserve(f\"{label}[reserve]\", est)\n",
    "\n",
    "        # ---------------- Call LLM ----------------\n",
    "        t0 = time.time()\n",
    "        try:\n",
    "            txt, usage = chat(self.model, self.sysm, prompt, self.temp, effective_max_t, force_json=force_json)\n",
    "            txt = sanitize_md(txt)\n",
    "        except Exception as e:\n",
    "            log_agent_call({\n",
    "                \"t\": now_utc_iso(), \"agent\": self.name, \"stage\": stage, \"model\": self.model,\n",
    "                \"label\": label, \"event\": \"error\", \"error\": str(e)\n",
    "            })\n",
    "            raise\n",
    "\n",
    "        dur = round(time.time() - t0, 3)\n",
    "        log_agent_call({\n",
    "            \"t\": now_utc_iso(), \"agent\": self.name, \"stage\": stage, \"model\": self.model,\n",
    "            \"label\": label, \"event\": \"llm_ok\", \"duration_s\": dur, \"usage\": usage\n",
    "        })\n",
    "\n",
    "        # ---------------- Reconcile cost ----------------\n",
    "        if usage and (self.tracker or envelope):\n",
    "            actual = (self.tracker or envelope.parent).estimate_cost(  # type: ignore[union-attr]\n",
    "                self.model,\n",
    "                usage.get(\"prompt_tokens\") or pt,\n",
    "                usage.get(\"completion_tokens\") or ct_budget,\n",
    "            )\n",
    "            if envelope:\n",
    "                envelope.reconcile(f\"{label}[actual]\", actual)\n",
    "            else:\n",
    "                self.tracker.reconcile(f\"{label}[actual]\", actual)  # type: ignore[union-attr]\n",
    "\n",
    "        # ---------------- Persist cache ----------------\n",
    "        payload = {\n",
    "            \"text\": txt or \"\",\n",
    "            \"usage\": usage,\n",
    "            \"ts\": now_utc_iso(),\n",
    "            \"model\": self.model,\n",
    "            \"temp\": self.temp,\n",
    "            \"stage\": stage,\n",
    "        }\n",
    "        cpath.write_text(json.dumps(payload, ensure_ascii=False), encoding=\"utf-8\")\n",
    "\n",
    "        return {\"text\": txt or \"\", \"cached\": False, \"usage\": usage, \"est_cost\": est}\n",
    "\n",
    "# -------------------------- PMRouter (wires models, temps, caps from config) --------------------------\n",
    "\n",
    "class PMRouter:\n",
    "    def __init__(self, tracker: CostTracker):\n",
    "        self.tracker = tracker\n",
    "        model_fast  = PL.get(\"MODEL_ID_FAST\", \"gpt-4o-mini\")\n",
    "        model_think = PL.get(\"MODEL_ID_THINK\", \"gpt-5-thinking\")\n",
    "\n",
    "        temp = float(PL.get(\"TEMPERATURE\", 0.2))\n",
    "        temp_author   = float(os.getenv(\"AUTHOR_TEMP\",   PL.get(\"AUTHOR_TEMPERATURE\",   temp)))\n",
    "        temp_editor   = float(os.getenv(\"EDITOR_TEMP\",   PL.get(\"EDITOR_TEMPERATURE\",   temp)))\n",
    "        temp_research = float(os.getenv(\"RESEARCH_TEMP\", PL.get(\"RESEARCH_TEMPERATURE\", 0.2)))\n",
    "\n",
    "        self.author = Agent(\n",
    "            \"Author\",\n",
    "            author_sys_prompt,\n",
    "            model_fast,                              # cheapest capable by default\n",
    "            temp_author,\n",
    "            int(PL.get(\"AUTHOR_MAX_TOKENS\", 7000)),\n",
    "            \"cache\",\n",
    "            tracker,\n",
    "        )\n",
    "        self.editor = Agent(\n",
    "            \"Editor\",\n",
    "            editor_sys_prompt,\n",
    "            model_fast,                              # can be swapped to THINK if needed\n",
    "            temp_editor,\n",
    "            int(PL.get(\"EDITOR_MAX_TOKENS\", 16384)),\n",
    "            \"cache\",\n",
    "            tracker,\n",
    "        )\n",
    "        self.research = (\n",
    "            Agent(\n",
    "                \"Research\",\n",
    "                research_sys_prompt,\n",
    "                model_fast,\n",
    "                temp_research,\n",
    "                int(PL.get(\"RESEARCH_MAX_TOKENS\", 1500)),\n",
    "                \"cache\",\n",
    "                tracker,\n",
    "            )\n",
    "            if PL.get(\"RESEARCH_ENABLED\", True)\n",
    "            else None\n",
    "        )\n",
    "\n",
    "        self.router_prompt = pmrouter_sys_prompt()\n",
    "        Path(\"logs\").mkdir(exist_ok=True)\n",
    "\n",
    "    # Optional helper to pick a model dynamically (cost-aware)\n",
    "    def pick_model(self, stage: str) -> str:\n",
    "        # Upgrade to THINK for heavy editor passes when configured\n",
    "        if stage == \"editor\" and int(PL.get(\"EDITOR_MAX_TOKENS\", 16384)) > 12000:\n",
    "            return PL.get(\"MODEL_ID_THINK\", PL.get(\"MODEL_ID_FAST\", \"gpt-4o-mini\"))\n",
    "        return PL.get(\"MODEL_ID_FAST\", \"gpt-4o-mini\")\n",
    "\n",
    "    def log(self, label: str, meta: Optional[Dict[str, Any]] = None) -> None:\n",
    "        meta = dict(meta or {})\n",
    "        meta[\"label\"] = label\n",
    "        meta[\"time\"] = now_utc_iso()\n",
    "        fname = f\"{now_utc_iso().replace(':','').replace('-','')}_{re.sub(r'[^A-Za-z0-9._-]+','_',label)}.json\"\n",
    "        p = Path(\"logs\") / fname\n",
    "        p.parent.mkdir(parents=True, exist_ok=True)\n",
    "        write_json(p, meta)\n",
    "\n",
    "print(\"Agents ready (Cell 4)\")"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": 8,
   "metadata": {},
   "outputs": [
    {
     "name": "stdout",
     "output_type": "stream",
     "text": [
      "[style] guide: content\\style\\style_guide.md\n",
      "[style] glossary: content\\style\\glossary.json | voice_cards: content\\style\\voice_cards.json\n",
      "[style] packs: content\\style\\packs.json\n",
      "Style writers ready\n"
     ]
    }
   ],
   "source": [
    "# Cell 5 — Style Guide & Glossary (pack-aware; POV voice cards; citation posture)\n",
    "\n",
    "from __future__ import annotations\n",
    "from pathlib import Path\n",
    "from typing import Dict, Any, List\n",
    "\n",
    "# Assumes from previous cells:\n",
    "# - CONFIG dict with: book_spec, pipeline, paths\n",
    "# - write_text, write_json\n",
    "\n",
    "BOOK: Dict[str, Any] = CONFIG[\"book_spec\"]\n",
    "PL: Dict[str, Any] = CONFIG[\"pipeline\"]\n",
    "PATHS: Dict[str, str] = CONFIG[\"paths\"]\n",
    "\n",
    "STYLE_DIR = Path(\"content/style\")\n",
    "STYLE_DIR.mkdir(parents=True, exist_ok=True)\n",
    "\n",
    "# -------------------- helpers --------------------\n",
    "\n",
    "def _parse_terminology(items) -> List[Dict[str, str]]:\n",
    "    \"\"\"Turn ['term: def', 'sensor: ...'] into [{'term': 'term','definition':'def'}, ...].\"\"\"\n",
    "    parsed: List[Dict[str, str]] = []\n",
    "    for raw in items or []:\n",
    "        if not isinstance(raw, str):\n",
    "            continue\n",
    "        if \":\" in raw:\n",
    "            term, definition = raw.split(\":\", 1)\n",
    "            parsed.append({\"term\": term.strip(), \"definition\": definition.strip()})\n",
    "        else:\n",
    "            parsed.append({\"term\": raw.strip(), \"definition\": \"\"})\n",
    "    return parsed\n",
    "\n",
    "def _mk_voice_cards(spec: Dict[str, Any]) -> Dict[str, Any]:\n",
    "    \"\"\"\n",
    "    Build POV voice cards for fiction (Blueprint §6: Voice Enforcement).\n",
    "    Uses story_assets.characters if available; otherwise returns a single generic card.\n",
    "    \"\"\"\n",
    "    if spec.get(\"kind\") != \"fiction\":\n",
    "        return {}\n",
    "    cards = {}\n",
    "    chars = (spec.get(\"story_assets\", {}) or {}).get(\"characters\", []) or []\n",
    "    if not chars:\n",
    "        cards[\"POV_Default\"] = {\n",
    "            \"pov\": \"third_limited\",\n",
    "            \"voice_summary\": \"Neutral cinematic voice; short sentences; no overt authorial intrusion.\",\n",
    "            \"diction_cues\": [\"concrete nouns\", \"active verbs\", \"minimal adverbs\"],\n",
    "            \"tempo_cues\": [\"tight pacing\", \"short paragraphs in action scenes\"],\n",
    "            \"taboos\": [\"exposition dumps\", \"breaking POV\", \"genre clichés\"],\n",
    "        }\n",
    "        return cards\n",
    "\n",
    "    for ch in chars:\n",
    "        name = ch.get(\"name\") or \"Unnamed\"\n",
    "        traits = \", \".join(ch.get(\"traits\", [])) if isinstance(ch.get(\"traits\"), list) else (ch.get(\"traits\") or \"\")\n",
    "        cards[name] = {\n",
    "            \"pov\": \"third_limited\",\n",
    "            \"voice_summary\": f\"POV aligned to {name}'s worldview; traits: {traits}.\",\n",
    "            \"diction_cues\": [\"favor concrete over abstract\", \"emotion shown via action beats\"],\n",
    "            \"tempo_cues\": [\"vary cadence with tension\", \"shorter lines in high-stakes beats\"],\n",
    "            \"taboos\": [\"head-hopping\", \"info-dumps\", \"stereotypes\"],\n",
    "        }\n",
    "    return cards\n",
    "\n",
    "def _citation_posture(spec: Dict[str, Any]) -> Dict[str, Any]:\n",
    "    \"\"\"Nonfiction citation guidance based on selected style.\"\"\"\n",
    "    style = (spec.get(\"citation_style\") or \"none\").lower()\n",
    "    if spec.get(\"kind\") == \"fiction\" or style == \"none\":\n",
    "        return {\"style\": \"none\", \"examples\": [], \"notes\": \"Fiction: no formal citations; use [n] markers for realism-sensitive facts if needed.\"}\n",
    "    if style == \"apa\":\n",
    "        return {\n",
    "            \"style\": \"APA\",\n",
    "            \"examples\": [\n",
    "                \"In-text: (Author, Year)\",\n",
    "                \"Ref: Author, A. A. (Year). Title. Publisher. https://doi.org/…\"\n",
    "            ],\n",
    "            \"notes\": \"Use author-year; page/para for quotes. Build bib via ResearchAgent → citations pipeline.\"\n",
    "        }\n",
    "    if style == \"chicago\":\n",
    "        return {\n",
    "            \"style\": \"Chicago\",\n",
    "            \"examples\": [\n",
    "                \"Footnote: 1. Author, Title (City: Publisher, Year), page.\",\n",
    "                \"Bibliography: Author. Title. City: Publisher, Year.\"\n",
    "            ],\n",
    "            \"notes\": \"Prefer notes-bibliography; ensure footnote markers align with [n].\"\n",
    "        }\n",
    "    if style == \"ieee\":\n",
    "        return {\n",
    "            \"style\": \"IEEE\",\n",
    "            \"examples\": [\n",
    "                \"In-text: [1]\",\n",
    "                \"Ref: [1] A. Author, “Article,” Journal, vol., no., pp., Year.\"\n",
    "            ],\n",
    "            \"notes\": \"Numeric ordering by first appearance; maintain stable mapping from [n] → [#].\"\n",
    "        }\n",
    "    # fallback\n",
    "    return {\"style\": style.upper(), \"examples\": [], \"notes\": \"Follow pack default; ensure unambiguous source mapping.\"}\n",
    "\n",
    "# -------------------- main generator --------------------\n",
    "\n",
    "def gen_style(spec: Dict[str, Any]) -> Dict[str, str]:\n",
    "    sg = (spec or {}).get(\"style_guide\", {}) or {}\n",
    "    voice = sg.get(\"voice\", \"—\")\n",
    "    formatting = sg.get(\"formatting\", \"—\")\n",
    "    citations_label = sg.get(\"citations\", \"—\")\n",
    "    terminology_items = sg.get(\"terminology\", [])\n",
    "    tone = (spec or {}).get(\"tone\", \"—\")\n",
    "\n",
    "    glossary = _parse_terminology(terminology_items)\n",
    "    packs = {\n",
    "        \"kind\": spec.get(\"kind\"),\n",
    "        \"genre_pack\": spec.get(\"genre_pack\"),\n",
    "        \"structure_pack\": spec.get(\"structure_pack\"),\n",
    "        \"style_pack\": spec.get(\"style_pack\"),\n",
    "        \"citation_style\": spec.get(\"citation_style\", \"none\"),\n",
    "        \"outline_schema\": spec.get(\"outline_schema\"),\n",
    "    }\n",
    "\n",
    "    # Citation posture for nonfiction\n",
    "    citation_guidance = _citation_posture(spec)\n",
    "\n",
    "    # POV voice cards (fiction only)\n",
    "    voice_cards = _mk_voice_cards(spec)\n",
    "\n",
    "    # Build Style Guide Markdown\n",
    "    lines = [\n",
    "        \"# Style Guide\",\n",
    "        \"\",\n",
    "        \"## Overview\",\n",
    "        f\"- **Kind:** {packs['kind']}\",\n",
    "        f\"- **Pack:** {packs.get('genre_pack') or packs.get('structure_pack')}\",\n",
    "        f\"- **Style Pack:** {packs['style_pack']}\",\n",
    "        f\"- **Outline Schema:** {packs['outline_schema']}\",\n",
    "        \"\",\n",
    "        \"## Tone & Voice\",\n",
    "        f\"- **Voice:** {voice}\",\n",
    "        f\"- **Tone:** {tone}\",\n",
    "        \"\",\n",
    "        \"## Formatting\",\n",
    "        f\"- {formatting}\",\n",
    "        \"- Headings policy: use **H2 for chapters**, **H3 for sections**; avoid H1.\",\n",
    "        \"- Keep paragraphs short. Prefer lists for dense information.\",\n",
    "        \"\",\n",
    "        \"## Citations\",\n",
    "        f\"- Declared: {citations_label}\",\n",
    "    ]\n",
    "\n",
    "    if citation_guidance[\"style\"] != \"none\":\n",
    "        lines += [\n",
    "            f\"- **Style:** {citation_guidance['style']}\",\n",
    "            \"- **Examples:**\",\n",
    "        ]\n",
    "        for ex in citation_guidance[\"examples\"]:\n",
    "            lines.append(f\"  - {ex}\")\n",
    "        lines.append(f\"- **Notes:** {citation_guidance['notes']}\")\n",
    "    else:\n",
    "        lines.append(\"- Fiction posture or citations disabled; use `[n]` markers for any real-world claims.\")\n",
    "\n",
    "    lines += [\n",
    "        \"\",\n",
    "        \"## Terminology\",\n",
    "    ]\n",
    "    if glossary:\n",
    "        lines += [f\"- **{t['term']}** — {t['definition']}\".rstrip() for t in glossary]\n",
    "    else:\n",
    "        lines.append(\"- _(none)_\")\n",
    "\n",
    "    lines += [\n",
    "        \"\",\n",
    "        \"## Rules\",\n",
    "        \"- Short, clear paragraphs; concrete > abstract.\",\n",
    "        \"- Add `[n]` wherever a claim needs a source (or realism-sensitive detail).\",\n",
    "        \"- No code fences in normal prose; only for actual code/diagrams.\",\n",
    "        \"- Avoid clichés and redundancy; preserve consistent POV and timeline.\",\n",
    "        \"\",\n",
    "        \"## Gate Awareness\",\n",
    "    ]\n",
    "    if spec.get(\"kind\") == \"fiction\":\n",
    "        lines += [\n",
    "            \"- **Value-Shift Gate:** Each scene moves from state A→B.\",\n",
    "            \"- **Hook Gate:** Chapter ends sustain momentum.\",\n",
    "            \"- **Voice Enforcement:** Adhere to POV voice cards.\",\n",
    "            \"- **Cliché & Redundancy Sweeps** on edit.\",\n",
    "            \"- **Antagonist Competence:** Avoid easy wins.\",\n",
    "        ]\n",
    "    else:\n",
    "        lines += [\n",
    "            \"- **Claim–Evidence Gate:** Important assertions must have planned/supporting sources.\",\n",
    "            \"- **Logical Flow Gate:** Thesis → claims → evidence → counterpoint → takeaway.\",\n",
    "            \"- **Citation Hygiene:** No orphan quotes; consistent style.\",\n",
    "            \"- **Terminology Consistency:** Define once; reuse exactly.\",\n",
    "        ]\n",
    "\n",
    "    style_md = \"\\n\".join(lines) + \"\\n\"\n",
    "\n",
    "    # -------------------- write artifacts --------------------\n",
    "    style_path = STYLE_DIR / \"style_guide.md\"\n",
    "    glossary_path = STYLE_DIR / \"glossary.json\"\n",
    "    packs_path = STYLE_DIR / \"packs.json\"\n",
    "    voice_cards_path = STYLE_DIR / \"voice_cards.json\"  # fiction only (may be empty)\n",
    "\n",
    "    write_text(style_path, style_md)\n",
    "    write_json(glossary_path, {\"terms\": glossary})\n",
    "    write_json(packs_path, packs)\n",
    "    if voice_cards:\n",
    "        write_json(voice_cards_path, voice_cards)\n",
    "    else:\n",
    "        # create an empty file for deterministic downstream imports\n",
    "        write_json(voice_cards_path, {})\n",
    "\n",
    "    # style manifest for quick sanity checks\n",
    "    manifest = {\n",
    "        \"kind\": packs[\"kind\"],\n",
    "        \"pack\": packs.get(\"genre_pack\") or packs.get(\"structure_pack\"),\n",
    "        \"style_pack\": packs[\"style_pack\"],\n",
    "        \"citation_style\": packs[\"citation_style\"],\n",
    "        \"files\": {\n",
    "            \"style_md\": style_path.as_posix(),\n",
    "            \"glossary_json\": glossary_path.as_posix(),\n",
    "            \"packs_json\": packs_path.as_posix(),\n",
    "            \"voice_cards_json\": voice_cards_path.as_posix(),\n",
    "        },\n",
    "    }\n",
    "    write_json(STYLE_DIR / \"style_manifest.json\", manifest)\n",
    "\n",
    "    print(\"[style] guide:\", style_path)\n",
    "    print(\"[style] glossary:\", glossary_path, \"| voice_cards:\", voice_cards_path)\n",
    "    print(\"[style] packs:\", packs_path)\n",
    "    return {\n",
    "        \"style_md\": style_path.as_posix(),\n",
    "        \"glossary_json\": glossary_path.as_posix(),\n",
    "        \"packs_json\": packs_path.as_posix(),\n",
    "        \"voice_cards_json\": voice_cards_path.as_posix(),\n",
    "    }\n",
    "\n",
    "# Execute immediately to materialize artifacts for downstream cells\n",
    "STYLE_OUTPUTS = gen_style(BOOK)\n",
    "print(\"Style writers ready\")"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": 9,
   "metadata": {},
   "outputs": [
    {
     "name": "stdout",
     "output_type": "stream",
     "text": [
      "Outline generator ready (Cell 6)\n"
     ]
    }
   ],
   "source": [
    "# Cell 6 — Outline Generation (pack-aware, deterministic, hardened — minor robustness & logging tweaks)\n",
    "\n",
    "from __future__ import annotations\n",
    "import json, re\n",
    "from pathlib import Path\n",
    "from typing import Any, Dict, List, Optional, Tuple\n",
    "\n",
    "# Assumes from previous cells:\n",
    "# - CONFIG (BOOK/PL/PATHS), write_text, write_json, read_json, has_file, now_utc_iso,\n",
    "#   sha1, stable_json_dumps, make_cache_key, log_exc, update_run_manifest\n",
    "# - router: PMRouter instance whose .author Agent supports stage + cache_payload\n",
    "\n",
    "BOOK: Dict[str, Any] = CONFIG[\"book_spec\"]\n",
    "PL: Dict[str, Any]   = CONFIG[\"pipeline\"]\n",
    "PATHS: Dict[str, str] = CONFIG[\"paths\"]\n",
    "\n",
    "# -------------------- Local helpers --------------------\n",
    "\n",
    "def _safe_int(x, default):\n",
    "    try:\n",
    "        return int(x)\n",
    "    except Exception:\n",
    "        return default\n",
    "\n",
    "def _looks_like_ipynb(obj) -> bool:\n",
    "    return isinstance(obj, dict) and {\"cells\", \"metadata\", \"nbformat\"} <= set(obj.keys())\n",
    "\n",
    "def _json_repair(s: str) -> str:\n",
    "    \"\"\"\n",
    "    Best-effort minimal repairs:\n",
    "      - remove BOM, smart→straight quotes\n",
    "      - strip fences & junk before first { or [\n",
    "      - remove trailing commas before } or ]\n",
    "      - slice to outermost braces/brackets\n",
    "    \"\"\"\n",
    "    s = (s or \"\").replace(\"\\r\\n\", \"\\n\").replace(\"\\r\", \"\\n\").strip()\n",
    "    s = re.sub(r\"^```(?:json|markdown)?\\s*\", \"\", s, flags=re.I | re.M)\n",
    "    s = re.sub(r\"\\s*```$\", \"\", s, flags=re.M)\n",
    "    if s and s[0] == \"\\ufeff\":\n",
    "        s = s[1:]\n",
    "    s = s.replace(\"“\", '\"').replace(\"”\", '\"').replace(\"’\", \"'\").replace(\"‘\", \"'\")\n",
    "    first_brace = s.find(\"{\"); first_bracket = s.find(\"[\")\n",
    "    cuts = [p for p in (first_brace, first_bracket) if p != -1]\n",
    "    if cuts:\n",
    "        s = s[min(cuts):]\n",
    "    s = re.sub(r\",(\\s*[}\\]])\", r\"\\1\", s)\n",
    "\n",
    "    def _slice_outer(text, open_ch, close_ch):\n",
    "        i, j = text.find(open_ch), text.rfind(close_ch)\n",
    "        return text[i:j+1] if (i != -1 and j != -1 and j > i) else text\n",
    "\n",
    "    if s.startswith(\"{\"):\n",
    "        s = _slice_outer(s, \"{\", \"}\")\n",
    "    elif s.startswith(\"[\"):\n",
    "        s = _slice_outer(s, \"[\", \"]\")\n",
    "    return s.strip()\n",
    "\n",
    "def parse_json_loose(raw: str):\n",
    "    # 1) strict\n",
    "    try:\n",
    "        d = json.loads(raw)\n",
    "        if _looks_like_ipynb(d): return None\n",
    "        return d\n",
    "    except Exception:\n",
    "        pass\n",
    "    # 2) repaired\n",
    "    repaired = _json_repair(raw)\n",
    "    try:\n",
    "        d = json.loads(repaired)\n",
    "        if _looks_like_ipynb(d): return None\n",
    "        return d\n",
    "    except Exception as e:\n",
    "        log_exc(\"outline_loose_repair\", e)\n",
    "    # 3) slice candidates\n",
    "    i_obj, j_obj = raw.find(\"{\"), raw.rfind(\"}\")\n",
    "    i_arr, j_arr = raw.find(\"[\"), raw.rfind(\"]\")\n",
    "    candidates = []\n",
    "    if i_obj != -1 and j_obj != -1 and j_obj > i_obj:\n",
    "        candidates.append(raw[i_obj:j_obj+1])\n",
    "    if i_arr != -1 and j_arr != -1 and j_arr > i_arr:\n",
    "        candidates.append(raw[i_arr:j_arr+1])\n",
    "    for cand in candidates:\n",
    "        try:\n",
    "            d = json.loads(cand)\n",
    "            if _looks_like_ipynb(d): continue\n",
    "            return d\n",
    "        except Exception as e:\n",
    "            log_exc(\"outline_loose_slice\", e)\n",
    "    # 4) last try\n",
    "    try:\n",
    "        d = json.loads(repaired)\n",
    "        if _looks_like_ipynb(d): return None\n",
    "        return d\n",
    "    except Exception as e:\n",
    "        log_exc(\"outline_loose_raw\", e)\n",
    "        return None\n",
    "\n",
    "def _hash_list(xs: List[str]) -> str:\n",
    "    return sha1(stable_json_dumps([x.strip() for x in (xs or [])]))\n",
    "\n",
    "def _chapter_template(spec: Dict[str, Any]) -> Dict[str, Any]:\n",
    "    return (spec.get(\"chapter_template\") or {})\n",
    "\n",
    "def _outline_constraints(spec: Dict[str, Any]) -> List[str]:\n",
    "    return (spec.get(\"outline_constraints\") or [])\n",
    "\n",
    "def _seed_titles(spec: Dict[str, Any]) -> List[str]:\n",
    "    return (spec.get(\"outline_seed\") or [])\n",
    "\n",
    "def brief(spec: Dict[str, Any]) -> str:\n",
    "    sg = (spec.get(\"style_guide\") or {})\n",
    "    tmpl = _chapter_template(spec)\n",
    "    oc   = _outline_constraints(spec)\n",
    "    seed = _seed_titles(spec)\n",
    "    lines = [\n",
    "        f\"Kind: {spec.get('kind','')}\",\n",
    "        f\"Pack: {spec.get('genre_pack') or spec.get('structure_pack')}\",\n",
    "        f\"OutlineSchema: {spec.get('outline_schema','')}\",\n",
    "        f\"Title: {spec.get('title','')}\",\n",
    "        f\"Audience: {spec.get('audience','')}\",\n",
    "        f\"Goal: {spec.get('goal','')}\",\n",
    "        f\"Tone: {spec.get('tone','')}\",\n",
    "        f\"Chapters: {spec.get('chapters','')}\",\n",
    "        f\"TargetWords: {spec.get('target_length_words','')}\",\n",
    "        f\"Style: {sg.get('formatting','')}\",\n",
    "    ]\n",
    "    if oc:\n",
    "        lines.append(\"OutlineConstraints: \" + \" | \".join(oc))\n",
    "    if seed:\n",
    "        lines.append(\"SeedTitles: \" + \" | \".join(seed))\n",
    "    if tmpl:\n",
    "        sections = \", \".join(tmpl.get(\"sections\", []))\n",
    "        end_matter = \", \".join(tmpl.get(\"end_matter\", []))\n",
    "        lines.append(f\"ChapterTemplate.sections: [{sections}]\")\n",
    "        if end_matter:\n",
    "            lines.append(f\"ChapterTemplate.end_matter: [{end_matter}]\")\n",
    "    return \"\\n\".join(lines)\n",
    "\n",
    "# -------------------- Normalizers (ensure schema compliance) --------------------\n",
    "\n",
    "def _normalize_fiction_outline(d: Dict[str, Any], n: int, spec: Dict[str, Any]) -> Dict[str, Any]:\n",
    "    tmpl = _chapter_template(spec)\n",
    "    tmpl_sections = tmpl.get(\"sections\") or [\n",
    "        \"Opening hook scene\",\"Problem escalation\",\"Twist or revelation\",\"Action or moral dilemma\",\"Cliffhanger or resolution\"\n",
    "    ]\n",
    "    chapters = d.get(\"chapters\") if isinstance(d.get(\"chapters\"), list) else []\n",
    "    seed = _seed_titles(spec)\n",
    "    fixed = []\n",
    "    for i in range(n):\n",
    "        base = chapters[i] if i < len(chapters) and isinstance(chapters[i], dict) else {}\n",
    "        num = i + 1\n",
    "        title = (base.get(\"title\") or (seed[i] if i < len(seed) else f\"Chapter {num}\")).strip()\n",
    "        if len(title.split()) > 7:\n",
    "            title = \" \".join(title.split()[:7])\n",
    "        sections = base.get(\"sections\") if isinstance(base.get(\"sections\"), list) else list(tmpl_sections)\n",
    "        beats = base.get(\"beats\") if isinstance(base.get(\"beats\"), dict) else {}\n",
    "        beats = {\n",
    "            \"stakes\": beats.get(\"stakes\") or \"—\",\n",
    "            \"turn_or_reversal\": beats.get(\"turn_or_reversal\") or \"—\",\n",
    "            \"set_piece\": beats.get(\"set_piece\") or \"—\",\n",
    "            \"promise\": beats.get(\"promise\") or \"—\",\n",
    "        }\n",
    "        fixed.append({\"number\": num, \"title\": title, \"sections\": sections, \"beats\": beats})\n",
    "    out = {\n",
    "        \"chapters\": fixed,\n",
    "        \"structure_variant\": spec.get(\"outline_schema\") or \"thriller_three_act\",\n",
    "    }\n",
    "    return out\n",
    "\n",
    "def _normalize_nonfiction_outline(d: Dict[str, Any], n: int, spec: Dict[str, Any]) -> Dict[str, Any]:\n",
    "    tmpl = _chapter_template(spec)\n",
    "    tmpl_sections = tmpl.get(\"sections\") or [\"Introduction\",\"Argument\",\"Evidence/Case\",\"Counterpoint\",\"Summary\"]\n",
    "    chapters = d.get(\"chapters\") if isinstance(d.get(\"chapters\"), list) else []\n",
    "    fixed = []\n",
    "    for i in range(n):\n",
    "        base = chapters[i] if i < len(chapters) and isinstance(chapters[i], dict) else {}\n",
    "        num = i + 1\n",
    "        title = (base.get(\"title\") or f\"Chapter {num}\").strip()\n",
    "        if len(title.split()) > 10:\n",
    "            title = \" \".join(title.split()[:10])\n",
    "        sections = base.get(\"sections\") if isinstance(base.get(\"sections\"), list) else list(tmpl_sections)\n",
    "        objectives = base.get(\"objectives\") if isinstance(base.get(\"objectives\"), list) else [\n",
    "            \"Clarify the claim\", \"Present evidence\", \"Address counterpoints\"\n",
    "        ]\n",
    "        fixed.append({\"number\": num, \"title\": title, \"sections\": sections, \"objectives\": objectives})\n",
    "    out = {\n",
    "        \"chapters\": fixed,\n",
    "        \"citation_style\": (spec.get(\"citation_style\") or \"none\"),\n",
    "        \"structure_variant\": spec.get(\"outline_schema\") or \"howto\",\n",
    "    }\n",
    "    return out\n",
    "\n",
    "def _fallback_outline(spec: Dict[str, Any]) -> Dict[str, Any]:\n",
    "    n = int(spec.get(\"chapters\") or 10)\n",
    "    if (spec.get(\"kind\") == \"fiction\"):\n",
    "        return _normalize_fiction_outline({\"chapters\":[]}, n, spec)\n",
    "    else:\n",
    "        return _normalize_nonfiction_outline({\"chapters\":[]}, n, spec)\n",
    "\n",
    "# -------------------- Core: generate outline via Author agent --------------------\n",
    "\n",
    "def gen_outline(spec: Dict[str, Any], tracker, router) -> Tuple[str, str]:\n",
    "    pj = PATHS[\"outline_json\"]\n",
    "    pm = PATHS[\"outline_md\"]\n",
    "    Path(pj).parent.mkdir(parents=True, exist_ok=True)\n",
    "    Path(pm).parent.mkdir(parents=True, exist_ok=True)\n",
    "\n",
    "    # If both artifacts already exist, reuse.\n",
    "    if has_file(pj) and has_file(pm):\n",
    "        return pj, pm\n",
    "\n",
    "    n = int(spec.get(\"chapters\", 10) or 10)\n",
    "    seed = _seed_titles(spec)\n",
    "    tmpl = _chapter_template(spec)\n",
    "    tmpl_sections = tmpl.get(\"sections\") or ([\"Introduction\",\"Argument\",\"Evidence/Case\",\"Counterpoint\",\"Summary\"] if spec.get(\"kind\")==\"nonfiction\" else\n",
    "                                             [\"Opening hook scene\",\"Problem escalation\",\"Twist or revelation\",\"Action or moral dilemma\",\"Cliffhanger or resolution\"])\n",
    "\n",
    "    # Request strict JSON schema depending on kind\n",
    "    if spec.get(\"kind\") == \"fiction\":\n",
    "        schema_hint = \"\"\"\n",
    "Return STRICT MINIFIED JSON ONLY:\n",
    "\n",
    "{\n",
    "  \"chapters\": [\n",
    "    {\n",
    "      \"number\": 1,\n",
    "      \"title\": \"…\",\n",
    "      \"sections\": [\"…\",\"…\",\"…\"],\n",
    "      \"beats\": {\"stakes\":\"…\",\"turn_or_reversal\":\"…\",\"set_piece\":\"…\",\"promise\":\"…\"}\n",
    "    }\n",
    "  ],\n",
    "  \"structure_variant\": \"hero_journey|thriller_three_act|romance_beat_sheet|mystery_puzzle|…\"\n",
    "}\n",
    "\"\"\"\n",
    "    else:\n",
    "        schema_hint = f\"\"\"\n",
    "Return STRICT MINIFIED JSON ONLY:\n",
    "\n",
    "{{\n",
    "  \"chapters\": [\n",
    "    {{\"number\": 1, \"title\": \"…\", \"sections\": [\"Introduction\",\"Argument\",\"Evidence/Case\",\"Counterpoint\",\"Summary\"], \"objectives\": [\"reader will learn …\",\"we will support claim …\"]}}\n",
    "  ],\n",
    "  \"citation_style\": \"{spec.get('citation_style','none')}\",\n",
    "  \"structure_variant\": \"howto|case_study|essay|report|memoir|history\"\n",
    "}}\n",
    "\"\"\"\n",
    "\n",
    "    constraints = _outline_constraints(spec)\n",
    "    brief_block = \"BRIEF\\n\" + brief(spec)\n",
    "    tmpl_line = \"ChapterTemplate.sections=[\" + \", \".join(tmpl_sections) + \"]\"\n",
    "    seed_line = (\"SeedTitles: \" + \" | \".join(seed)) if seed else \"SeedTitles: (none)\"\n",
    "\n",
    "    instr = (\n",
    "        f\"{schema_hint}\\n\"\n",
    "        f\"- Exactly {n} chapters.\\n\"\n",
    "        \"- Titles should be concise (≤7 words fiction; ≤10 words nonfiction).\\n\"\n",
    "        \"- Follow ChapterTemplate sections for each chapter unless clearly inappropriate.\\n\"\n",
    "        \"- Respect OutlineConstraints.\\n\"\n",
    "        \"- NO prose, NO markdown fences, JSON ONLY.\"\n",
    "    )\n",
    "\n",
    "    prompt = \"\\n\\n\".join([brief_block, tmpl_line, seed_line, \"INSTRUCTIONS\", instr])\n",
    "\n",
    "    # Build deterministic cache payload & key\n",
    "    pv = PL.get(\"PROMPT_VERSIONS\", {}).get(\"outline\", \"v3\")\n",
    "    payload = {\n",
    "        \"pv\": pv,\n",
    "        \"kind\": spec.get(\"kind\"),\n",
    "        \"pack\": spec.get(\"genre_pack\") or spec.get(\"structure_pack\"),\n",
    "        \"n\": n,\n",
    "        \"seed_hash\": _hash_list(seed),\n",
    "        \"tmpl_hash\": sha1(stable_json_dumps(tmpl)),\n",
    "        \"oc_hash\": _hash_list(constraints),\n",
    "        \"title\": spec.get(\"title\",\"\")[:64],\n",
    "    }\n",
    "    cache_key = make_cache_key(PL.get(\"CACHE_PREFIXES\", {}).get(\"outline\",\"oln\"), payload)\n",
    "\n",
    "    # Token budget\n",
    "    max_t = _safe_int(PL.get(\"OUTLINE_MAX_TOKENS\", 1800), 1800)\n",
    "    if PL.get(\"ULTRA_BUDGET_MODE\"):\n",
    "        max_t = max(900, int(max_t * 0.6))\n",
    "\n",
    "    # Call Author agent (stage=\"outline\")\n",
    "    R = router.author.run(\n",
    "        \"outline\",\n",
    "        prompt,\n",
    "        stage=\"outline\",\n",
    "        cache_payload=payload,\n",
    "        cache_key=cache_key,\n",
    "        max_t=max_t,\n",
    "        force_json=True,\n",
    "    )\n",
    "    model_text = (R.get(\"text\") or \"\").strip()\n",
    "    try:\n",
    "        write_text(\"logs/outline_model_raw.txt\", model_text)\n",
    "    except Exception:\n",
    "        pass\n",
    "\n",
    "    d = parse_json_loose(model_text)\n",
    "\n",
    "    # Retry once with stricter reminder if needed\n",
    "    if not (isinstance(d, dict) and isinstance(d.get(\"chapters\"), list) and d[\"chapters\"]):\n",
    "        strict_prompt = prompt + \"\\n\\nSTRICT: Return JSON object only — no prose.\"\n",
    "        R2 = router.author.run(\n",
    "            \"outline_retry1\",\n",
    "            strict_prompt,\n",
    "            stage=\"outline\",\n",
    "            cache_payload=payload,\n",
    "            cache_key=cache_key + \":r1\",\n",
    "            max_t=max_t,\n",
    "            force_json=True,\n",
    "        )\n",
    "        model_text2 = (R2.get(\"text\") or \"\").strip()\n",
    "        try:\n",
    "            write_text(\"logs/outline_model_raw_retry1.txt\", model_text2)\n",
    "        except Exception:\n",
    "            pass\n",
    "        d = parse_json_loose(model_text2)\n",
    "\n",
    "    # Fallback to deterministic local outline\n",
    "    if not (isinstance(d, dict) and isinstance(d.get(\"chapters\"), list) and d[\"chapters\"]):\n",
    "        d = _fallback_outline(spec)\n",
    "        write_json(\n",
    "            \"logs/outline_parse_fallback.json\",\n",
    "            {\"reason\": \"Invalid or empty JSON from model\", \"ts\": now_utc_iso()},\n",
    "        )\n",
    "\n",
    "    # Normalize to schema\n",
    "    if spec.get(\"kind\") == \"fiction\":\n",
    "        final = _normalize_fiction_outline(d, n, spec)\n",
    "    else:\n",
    "        final = _normalize_nonfiction_outline(d, n, spec)\n",
    "\n",
    "    # Persist artifacts\n",
    "    write_json(pj, final)\n",
    "\n",
    "    # Markdown index (chapters + section headings)\n",
    "    lines = [f\"# Outline: {spec.get('title','')}\", \"\"]\n",
    "    for ch in final[\"chapters\"]:\n",
    "        lines.append(f\"## Chapter {ch['number']}: {ch['title']}\")\n",
    "        secs = ch.get(\"sections\") or []\n",
    "        for idx, s in enumerate(secs, 1):\n",
    "            lines.append(f\"- {idx}. {s}\")\n",
    "        if spec.get(\"kind\") == \"fiction\":\n",
    "            b = ch.get(\"beats\", {})\n",
    "            lines.append(f\"  - _Beats:_ stakes={b.get('stakes','—')}; turn={b.get('turn_or_reversal','—')}; set_piece={b.get('set_piece','—')}; promise={b.get('promise','—')}\")\n",
    "        lines.append(\"\")\n",
    "    Path(pm).parent.mkdir(parents=True, exist_ok=True)\n",
    "    write_text(pm, \"\\n\".join(lines).strip() + \"\\n\")\n",
    "\n",
    "    # Update run manifest\n",
    "    try:\n",
    "        update_run_manifest({\n",
    "            \"title\": spec.get(\"title\"),\n",
    "            \"kind\": spec.get(\"kind\"),\n",
    "            \"pack\": spec.get(\"genre_pack\") or spec.get(\"structure_pack\"),\n",
    "            \"outline_schema\": spec.get(\"outline_schema\"),\n",
    "            \"chapters_planned\": n,\n",
    "            \"outline_paths\": {\"json\": pj, \"md\": pm},\n",
    "            \"outline_generated_at\": now_utc_iso(),\n",
    "        })\n",
    "    except Exception as e:\n",
    "        log_exc(\"outline_manifest_update\", e)\n",
    "\n",
    "    return pj, pm\n",
    "\n",
    "print(\"Outline generator ready (Cell 6)\")"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": 10,
   "id": "e3c8246b",
   "metadata": {},
   "outputs": [
    {
     "name": "stdout",
     "output_type": "stream",
     "text": [
      "Continuity/Knowledge Merge ready (Cell 6b)\n"
     ]
    }
   ],
   "source": [
    "# Cell 6b — Continuity/Knowledge Merge (pack-aware; deterministic hashing)\n",
    "\n",
    "from __future__ import annotations\n",
    "from pathlib import Path\n",
    "from typing import Dict, Any, List, Optional\n",
    "import json, re\n",
    "\n",
    "# Assumes from previous cells:\n",
    "# - CONFIG (BOOK/PL/PATHS), write_text, write_json, read_json, now_utc_iso, sha1, stable_json_dumps, log_exc\n",
    "# - Outline already written; edited chapters live at content/edits/<ch>/{chapter_xx.md, claims_report.json, continuity_notes.md}\n",
    "\n",
    "BOOK: Dict[str, Any] = CONFIG[\"book_spec\"]\n",
    "PL: Dict[str, Any]   = CONFIG[\"pipeline\"]\n",
    "PATHS: Dict[str, str] = CONFIG[\"paths\"]\n",
    "\n",
    "def _ensure_dir(p: Path) -> None:\n",
    "    p.mkdir(parents=True, exist_ok=True)\n",
    "\n",
    "def _safe_json(path: Path) -> Any:\n",
    "    try:\n",
    "        return read_json(path.as_posix()) or {}\n",
    "    except Exception:\n",
    "        try:\n",
    "            return json.loads(path.read_text(encoding=\"utf-8\"))\n",
    "        except Exception:\n",
    "            return {}\n",
    "\n",
    "def _default_ref_paths(kind: str) -> Dict[str, Path]:\n",
    "    if kind.lower() == \"fiction\":\n",
    "        p = Path(PATHS.get(\"story_bible\", \"references/story_bible.json\"))\n",
    "        return {\"ref\": p}\n",
    "    else:\n",
    "        p = Path(PATHS.get(\"knowledge_base\", \"references/knowledge_base.json\"))\n",
    "        return {\"ref\": p}\n",
    "\n",
    "def _load_ref(kind: str) -> Dict[str, Any]:\n",
    "    rp = _default_ref_paths(kind)[\"ref\"]\n",
    "    if rp.exists():\n",
    "        d = _safe_json(rp)\n",
    "        if isinstance(d, dict):\n",
    "            return d\n",
    "    # initialize new structure\n",
    "    if kind.lower() == \"fiction\":\n",
    "        return {\n",
    "            \"characters\": {},\n",
    "            \"timeline\": [],\n",
    "            \"motifs\": [],\n",
    "            \"world_rules\": {},\n",
    "            \"threads_open\": [],\n",
    "            \"threads_closed\": []\n",
    "        }\n",
    "    else:\n",
    "        return {\n",
    "            \"thesis\": BOOK.get(\"thesis\",\"\") if \"thesis\" in BOOK else \"\",\n",
    "            \"key_claims\": [],\n",
    "            \"citations\": BOOK.get(\"citations\", []),\n",
    "            \"figures\": [],\n",
    "            \"outline_links\": []\n",
    "        }\n",
    "\n",
    "def _write_ref(kind: str, d: Dict[str, Any]) -> str:\n",
    "    rp = _default_ref_paths(kind)[\"ref\"]\n",
    "    _ensure_dir(rp.parent)\n",
    "    write_json(rp.as_posix(), d)\n",
    "    # return a stable hash the caching layer can use\n",
    "    try:\n",
    "        raw = json.dumps(d, ensure_ascii=False, separators=(\",\", \":\"), sort_keys=True)\n",
    "    except Exception:\n",
    "        raw = str(d)\n",
    "    return sha1(raw)\n",
    "\n",
    "def _parse_bullets(md: str) -> List[str]:\n",
    "    out = []\n",
    "    for ln in (md or \"\").splitlines():\n",
    "        s = ln.strip()\n",
    "        if not s: continue\n",
    "        if s.startswith((\"-\", \"*\")):\n",
    "            out.append(s.lstrip(\"-* \").strip())\n",
    "    return out\n",
    "\n",
    "def _merge_fiction(bible: Dict[str, Any], ch: int, notes_md: str) -> Dict[str, Any]:\n",
    "    # Minimal, safe merge: add timeline entry + capture salient bullets from notes\n",
    "    bullets = _parse_bullets(notes_md)\n",
    "    if bullets:\n",
    "        bible.setdefault(\"timeline\", []).append({\n",
    "            \"ch\": ch,\n",
    "            \"notes\": bullets[:12]  # keep short to avoid bloat\n",
    "        })\n",
    "    # Heuristic: detect thread open/close tags from notes like \"[open:#id]\" \"[close:#id]\"\n",
    "    opens = re.findall(r\"\\[open:#([A-Za-z0-9_-]+)\\]\", notes_md or \"\", flags=re.I)\n",
    "    closes = re.findall(r\"\\[close:#([A-Za-z0-9_-]+)\\]\", notes_md or \"\", flags=re.I)\n",
    "    for tid in opens:\n",
    "        if not any(t.get(\"id\")==tid for t in bible.get(\"threads_open\", [])):\n",
    "            bible.setdefault(\"threads_open\", []).append({\"id\": tid, \"opened_at\": ch})\n",
    "    for tid in closes:\n",
    "        if not any(t.get(\"id\")==tid for t in bible.get(\"threads_closed\", [])):\n",
    "            bible.setdefault(\"threads_closed\", []).append({\"id\": tid, \"closed_at\": ch})\n",
    "            # also remove from open if present\n",
    "            bible[\"threads_open\"] = [t for t in bible.get(\"threads_open\", []) if t.get(\"id\") != tid]\n",
    "    return bible\n",
    "\n",
    "def _merge_nonfiction(kb: Dict[str, Any], ch: int, notes_md: str, claims: List[Dict[str, Any]]) -> Dict[str, Any]:\n",
    "    # Add claims (dedup by normalized text)\n",
    "    def norm(s: str) -> str:\n",
    "        return re.sub(r\"\\s+\", \" \", (s or \"\").strip().lower())\n",
    "    seen = {norm(c.get(\"text\",\"\")) for c in kb.get(\"key_claims\", []) if isinstance(c, dict)}\n",
    "    for c in claims or []:\n",
    "        text = (c or {}).get(\"text\",\"\")\n",
    "        if not text: continue\n",
    "        if norm(text) in seen: continue\n",
    "        kb.setdefault(\"key_claims\", []).append({\n",
    "            \"id\": c.get(\"id\") or len(kb.get(\"key_claims\", [])) + 1,\n",
    "            \"text\": text,\n",
    "            \"importance\": \"high\" if (c.get(\"confidence\") in (\"low\",\"med\")) else \"med\",\n",
    "            \"evidence_plan\": [c.get(\"category\")] if c.get(\"category\") else []\n",
    "        })\n",
    "        seen.add(norm(text))\n",
    "    # Link chapter to claims added this pass\n",
    "    added_ids = [c.get(\"id\") for c in kb.get(\"key_claims\", []) if isinstance(c, dict)]\n",
    "    kb.setdefault(\"outline_links\", [])\n",
    "    kb[\"outline_links\"].append({\"chapter\": ch, \"claims\": added_ids, \"citations\": []})\n",
    "    # Notes to kb['notes'] (rolling)\n",
    "    if notes_md and notes_md.strip():\n",
    "        kb[\"notes\"] = (kb.get(\"notes\",\"\") + \"\\n\" + notes_md.strip()).strip()\n",
    "    return kb\n",
    "\n",
    "def merge_continuity_for_chapter(ch: int, *, kind: Optional[str] = None) -> str:\n",
    "    \"\"\"\n",
    "    Merge editor outputs for a single chapter into Story Bible (fiction) or Knowledge Base (nonfiction).\n",
    "    Returns new hash string of the reference artifact.\n",
    "    \"\"\"\n",
    "    kind = (kind or BOOK.get(\"kind\") or \"fiction\").lower()\n",
    "    base = _load_ref(kind)\n",
    "    notes_p = Path(f\"content/edits/{ch:02d}/continuity_notes.md\")\n",
    "    claims_p = Path(f\"content/edits/{ch:02d}/claims_report.json\")\n",
    "    notes_md = notes_p.read_text(encoding=\"utf-8\", errors=\"ignore\") if notes_p.exists() else \"\"\n",
    "    claims = _safe_json(claims_p) or {}\n",
    "    claims_list = claims.get(\"claims\") if isinstance(claims, dict) else []\n",
    "\n",
    "    if kind == \"fiction\":\n",
    "        base = _merge_fiction(base, ch, notes_md)\n",
    "    else:\n",
    "        base = _merge_nonfiction(base, ch, notes_md, claims_list if isinstance(claims_list, list) else [])\n",
    "\n",
    "    h = _write_ref(kind, base)\n",
    "    # optional: record a small breadcrumb\n",
    "    try:\n",
    "        Path(\"logs\").mkdir(exist_ok=True)\n",
    "        write_json(f\"logs/merge_ch{ch:02d}.json\", {\"chapter\": ch, \"kind\": kind, \"hash\": h, \"ts\": now_utc_iso()})\n",
    "    except Exception as e:\n",
    "        log_exc(\"merge_continuity_breadcrumb\", e)\n",
    "    return h\n",
    "\n",
    "def merge_all_continuity() -> Dict[str, Any]:\n",
    "    \"\"\"\n",
    "    Merge over all planned chapters (from outline or stats).\n",
    "    \"\"\"\n",
    "    outline = _safe_json(\"content/outline/outline.json\")\n",
    "    n = len(outline.get(\"chapters\", [])) if isinstance(outline.get(\"chapters\"), list) else int((read_json(\"build/stats.json\") or {}).get(\"chapters_planned\") or 0)\n",
    "    merged = []\n",
    "    for ch in range(1, max(1, n) + 1):\n",
    "        try:\n",
    "            h = merge_continuity_for_chapter(ch, kind=BOOK.get(\"kind\"))\n",
    "            merged.append({\"chapter\": ch, \"hash\": h})\n",
    "        except Exception as e:\n",
    "            log_exc(f\"merge_ch{ch:02d}\", e)\n",
    "    # quick manifest touch\n",
    "    try:\n",
    "        man = read_json(\"logs/run_manifest.json\") or {}\n",
    "        man[\"continuity_merged_at\"] = now_utc_iso()\n",
    "        man[\"continuity_entries\"] = merged\n",
    "        write_json(\"logs/run_manifest.json\", man)\n",
    "    except Exception as e:\n",
    "        log_exc(\"merge_manifest_update\", e)\n",
    "    print(f\"[merge] continuity merged for {len(merged)} chapter(s).\")\n",
    "    return {\"merged\": merged}\n",
    "\n",
    "print(\"Continuity/Knowledge Merge ready (Cell 6b)\")"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": 11,
   "metadata": {},
   "outputs": [],
   "source": [
    "# Cell 7 — Per-Chapter Loop (two-pass authoring, tighten micro-pass, strict editor tags, selective research, gate metrics)\n",
    "\n",
    "from __future__ import annotations\n",
    "from pathlib import Path\n",
    "from typing import Dict, List, Tuple, Optional, Any\n",
    "import json, re\n",
    "\n",
    "# Assumes from previous cells:\n",
    "# - CONFIG dict with BOOK/PL/PATHS\n",
    "# - router: PMRouter instance\n",
    "# - tracker: CostTracker instance\n",
    "# - write_text, read_text, write_json, read_json, has_file, now_utc_iso, sha1, sanitize_md\n",
    "# - compute_chapter_word_targets, log_exc\n",
    "# - sanitize_chapter_markdown from this cell\n",
    "# - ChapterEnvelope from utils\n",
    "\n",
    "BOOK: Dict[str, Any] = CONFIG[\"book_spec\"]\n",
    "PL: Dict[str, Any]   = CONFIG[\"pipeline\"]\n",
    "PATHS: Dict[str, str] = CONFIG[\"paths\"]\n",
    "\n",
    "# ----------------------------- Small utilities -----------------------------\n",
    "\n",
    "def _ensure_dir(p: Path) -> None:\n",
    "    p.mkdir(parents=True, exist_ok=True)\n",
    "\n",
    "def _safe_int(x, default: int) -> int:\n",
    "    try:\n",
    "        return int(x)\n",
    "    except Exception:\n",
    "        return default\n",
    "\n",
    "def _safe_float(x, default: float) -> float:\n",
    "    try:\n",
    "        return float(x)\n",
    "    except Exception:\n",
    "        return default\n",
    "\n",
    "def _bool(x, default: bool) -> bool:\n",
    "    if x is None: return default\n",
    "    if isinstance(x, bool): return x\n",
    "    s = str(x).strip().lower()\n",
    "    if s in {\"1\",\"true\",\"yes\",\"y\",\"on\"}: return True\n",
    "    if s in {\"0\",\"false\",\"no\",\"n\",\"off\"}: return False\n",
    "    return default\n",
    "\n",
    "# ----------------------------- Path helpers --------------------------------\n",
    "\n",
    "def ch_dir(ch, sub) -> Path:\n",
    "    d = Path(f\"content/{sub}/{int(ch):02d}\")\n",
    "    _ensure_dir(d)\n",
    "    return d\n",
    "\n",
    "def outline_ch(pj: str, ch: int) -> Dict[str, Any]:\n",
    "    d = read_json(pj) or {}\n",
    "    for it in d.get(\"chapters\", []):\n",
    "        if int(it.get(\"number\", -1)) == int(ch):\n",
    "            return it\n",
    "    raise KeyError(f\"Chapter {ch} not in outline\")\n",
    "\n",
    "# ----------------------------- Parse editor blocks --------------------------\n",
    "\n",
    "def parse_editor_blocks(t: str) -> Tuple[str, Dict[str, Any], str]:\n",
    "    \"\"\"\n",
    "    Extract three tagged blocks and return (md, claims_dict, notes_md).\n",
    "    Recognizes both CONTINUITY_OR_ARGUMENT_NOTES_MD and CONTINUITY_NOTES_MD for backward-compat.\n",
    "    Hardened against missing tags and malformed JSON.\n",
    "    \"\"\"\n",
    "    def ex(tag: str) -> str:\n",
    "        m = re.search(r\"<\" + re.escape(tag) + r\">\\s*([\\s\\S]*?)\\s*</\" + re.escape(tag) + r\">\", t, flags=re.DOTALL | re.IGNORECASE)\n",
    "        return (m.group(1) if m else \"\").strip()\n",
    "\n",
    "    md_raw = ex(\"DRAFT_EDITED_MD\")\n",
    "    claims_raw = ex(\"CLAIMS_REPORT_JSON\")\n",
    "    notes_raw = ex(\"CONTINUITY_OR_ARGUMENT_NOTES_MD\") or ex(\"CONTINUITY_NOTES_MD\")\n",
    "\n",
    "    # Claims JSON\n",
    "    claims: Dict[str, Any]\n",
    "    try:\n",
    "        parsed = json.loads(sanitize_md(claims_raw)) if claims_raw else {}\n",
    "        if not isinstance(parsed, dict):\n",
    "            parsed = {\"raw\": parsed}\n",
    "        parsed.setdefault(\"claims\", [])\n",
    "        parsed.setdefault(\"issues\", [])\n",
    "        parsed.setdefault(\"notes\", \"\")\n",
    "        claims = parsed\n",
    "    except Exception:\n",
    "        claims = {\"raw\": sanitize_md(claims_raw or \"\")}\n",
    "\n",
    "    return sanitize_md(md_raw), claims, sanitize_md(notes_raw)\n",
    "\n",
    "# ---------- Generic helpers for end-matter & editor instruction ----------\n",
    "\n",
    "def end_matter_spec(spec: Dict[str, Any]) -> Dict[str, Any]:\n",
    "    \"\"\"\n",
    "    Build a generic end-matter spec from book_spec.\n",
    "    Returns:\n",
    "      { \"labels\": [{\"label\": \"...\", \"heading\": \"### ...\"}], \"heading_fmt\": \"### {label}\" }\n",
    "    \"\"\"\n",
    "    tmpl = spec.get(\"chapter_template\") or {}\n",
    "    items = tmpl.get(\"end_matter\") or []\n",
    "    heading_fmt = tmpl.get(\"end_matter_heading_format\", \"### {label}\")\n",
    "\n",
    "    labels = []\n",
    "    seen = set()\n",
    "    for x in items:\n",
    "        if isinstance(x, dict):\n",
    "            label = (x.get(\"label\") or x.get(\"title\") or str(x)).strip()\n",
    "            heading = x.get(\"heading\")\n",
    "        else:\n",
    "            s = str(x)\n",
    "            label = s.split(\":\", 1)[0].strip() if \":\" in s else s.strip()\n",
    "            heading = None\n",
    "        if not label:\n",
    "            continue\n",
    "        key = label.lower()\n",
    "        if key in seen:\n",
    "            continue\n",
    "        seen.add(key)\n",
    "        labels.append({\"label\": label, \"heading\": heading or heading_fmt.format(label=label)})\n",
    "\n",
    "    return {\"labels\": labels, \"heading_fmt\": heading_fmt}\n",
    "\n",
    "EDITOR_AGENT_PREAMBLE = \"\"\"\n",
    "You are EditorAgent, responsible for polishing and validating the AuthorAgent's drafts.\n",
    "\n",
    "Responsibilities\n",
    "1) Editing & Cleanup\n",
    "   - Improve clarity, readability, and flow.\n",
    "   - Enforce tone, style, and heading rules from the style guide.\n",
    "   - Remove redundancy, filler, and unnecessary complexity.\n",
    "\n",
    "2) Fact-Check Preparation\n",
    "   - Insert [n] markers where claims need supporting evidence.\n",
    "   - Collect a structured list of those claims for ResearchAgent.\n",
    "\n",
    "3) Output Requirements\n",
    "   - Deliver a polished, unified chapter ready for assembly.\n",
    "   - Produce a parallel claims report mapping [n] markers to unresolved facts.\n",
    "   - Add brief continuity/argument notes.\n",
    "\"\"\".strip()\n",
    "\n",
    "def build_editor_instruction(\n",
    "    dtext: str,\n",
    "    spec: Dict[str, Any],\n",
    "    oc: Dict[str, Any],\n",
    "    ch: int,\n",
    "    cfg: Dict[str, Any],\n",
    "    tw: Optional[int] = None,\n",
    ") -> str:\n",
    "    \"\"\"Strict editor instructions with exact tag protocol and end-matter policy.\"\"\"\n",
    "    tmpl = spec.get(\"chapter_template\", {}) or {}\n",
    "    ch_fmt = tmpl.get(\"chapter_heading_format\", \"## Chapter {number}: {title}\")\n",
    "    chapter_heading = ch_fmt.format(number=ch, title=oc.get(\"title\", \"\"))\n",
    "\n",
    "    render_heads = bool(tmpl.get(\"render_section_headings\", False))\n",
    "    sec_fmt = tmpl.get(\"section_heading_format\", \"### {title}\")\n",
    "    sec_fmt_preview = sec_fmt.replace(\"{index}\", \"N\").replace(\"{title}\", \"Title\")\n",
    "\n",
    "    em = end_matter_spec(spec)\n",
    "    em_labels = em[\"labels\"]\n",
    "    em_order_preview = (\"\\n\".join([f\"  {i + 1}) {item['heading']}\" for i, item in enumerate(em_labels)]) if em_labels else \"  (none)\")\n",
    "    target_words = _safe_int(tw, 0) or None\n",
    "\n",
    "    sg = spec.get(\"style_guide\", {})\n",
    "    voice = sg.get(\"voice\", \"\")\n",
    "    formatting = sg.get(\"formatting\", \"\")\n",
    "    terms = \", \".join(sg.get(\"terminology\", []))\n",
    "\n",
    "    parts: List[str] = [\n",
    "        EDITOR_AGENT_PREAMBLE,\n",
    "        \"\\n\\nSTYLE GUIDE SNAPSHOT\\n\",\n",
    "        f\"- Voice: {voice}\\n\" if voice else \"\",\n",
    "        f\"- Formatting: {formatting}\\n\" if formatting else \"\",\n",
    "        f\"- Terminology: {terms}\\n\" if terms else \"\",\n",
    "        \"\\nINPUT_MD:\\n\",\n",
    "        dtext,\n",
    "        \"\\n\\nEditing directives (follow strictly)\\n\",\n",
    "        f\"- Keep ONE chapter heading at the top, exactly:\\n  {chapter_heading}\\n\",\n",
    "        \"- Do NOT repeat the chapter title inside the body.\\n\",\n",
    "        (f\"- If section headings are enabled, normalize to: {sec_fmt_preview}; renumber 1..N.\\n\" if render_heads\n",
    "         else \"- Section headings disabled: remove template beat subheads; keep only organic, helpful subheads.\\n\"),\n",
    "        \"- End-matter policy (STRICT):\\n\",\n",
    "        \"  • Keep exactly ONE end-matter block at EOF in the configured order.\\n\",\n",
    "        \"  • Treat label/colon or bold/italic variants as end-matter and move to EOF.\\n\",\n",
    "        \"  • Merge duplicates; keep the most informative content.\\n\",\n",
    "        \"  • Use the configured end-matter heading format:\\n\",\n",
    "        em_order_preview, \"\\n\",\n",
    "        \"  • Do NOT invent end-matter if no content exists.\\n\",\n",
    "        \"- Preserve timeline, names, and technical terms; no new plot facts.\\n\",\n",
    "        \"- Past tense, third-person limited (fiction) or pack-appropriate voice (nonfiction).\\n\",\n",
    "        \"- Collapse excessive blank lines; strip trailing spaces.\\n\",\n",
    "        (f\"- Target ~{target_words} words (±15%).\\n\" if target_words else \"\"),\n",
    "        \"- Add [n] sparingly for verifiable claims (tech/ops/AI/geo/policy).\\n\",\n",
    "        \"\\nOUTPUT FORMAT (MANDATORY TAGS)\\n\",\n",
    "        \"<DRAFT_EDITED_MD>\\n\",\n",
    "        \"# Full unified chapter in Markdown with exactly one top chapter heading.\\n\",\n",
    "        \"</DRAFT_EDITED_MD>\\n\",\n",
    "        \"<CLAIMS_REPORT_JSON>\\n\",\n",
    "        '{ \"claims\": [ {\"id\":1,\"marker\":\"[1]\",\"text\":\"…\",\"category\":\"tech|ops|ai\",\"confidence\":\"low|med|high\"} ], \"issues\": [], \"notes\": \"\" }\\n',\n",
    "        \"</CLAIMS_REPORT_JSON>\\n\",\n",
    "        \"<CONTINUITY_OR_ARGUMENT_NOTES_MD>\\n\",\n",
    "        \"- Where the chapter ends; character states; items in play; open threads.\\n\",\n",
    "        \"</CONTINUITY_OR_ARGUMENT_NOTES_MD>\\n\",\n",
    "    ]\n",
    "    return \"\".join(parts)\n",
    "\n",
    "# ---------- Post-edit sanitizer (structure enforcement) ----------\n",
    "\n",
    "def sanitize_chapter_markdown(md: str, spec: Dict[str, Any]) -> str:\n",
    "    \"\"\"\n",
    "    Enforce:\n",
    "      - exactly one chapter heading (synthesize if missing)\n",
    "      - unwrap code fences\n",
    "      - collect end-matter variants to EOF in defined order\n",
    "      - normalize/remove section heads per template; renumber sequentially if enabled\n",
    "      - trim trailing spaces & collapse extra blank lines\n",
    "    \"\"\"\n",
    "    tmpl = spec.get(\"chapter_template\") or {}\n",
    "    md = (md or \"\").replace(\"\\r\\n\", \"\\n\").replace(\"\\r\", \"\\n\")\n",
    "\n",
    "    # Single top \"## Chapter N: Title\"\n",
    "    ch_heading_rx = re.compile(r\"^##\\s*Chapter\\s+\\d+\\s*[:\\-–—]\\s*.+\\s*$\", re.MULTILINE | re.IGNORECASE)\n",
    "    lines = md.strip().splitlines()\n",
    "    first = None\n",
    "    for i, ln in enumerate(lines):\n",
    "        if ch_heading_rx.match(ln):\n",
    "            if first is None:\n",
    "                first = i\n",
    "            else:\n",
    "                lines[i] = \"\"\n",
    "    md = \"\\n\".join(lines).strip()\n",
    "    if not ch_heading_rx.search(md):\n",
    "        number = _safe_int(spec.get(\"current_chapter_number\", 1), 1)\n",
    "        title = spec.get(\"current_chapter_title\", \"\") or spec.get(\"title\", \"\")\n",
    "        ch_fmt = tmpl.get(\"chapter_heading_format\", \"## Chapter {number}: {title}\")\n",
    "        md = ch_fmt.format(number=number, title=title) + (\"\\n\\n\" + md if md else \"\")\n",
    "\n",
    "    # Unwrap fenced blocks\n",
    "    md = re.sub(r\"```+(\\w+)?\\s*(.*?)```+\", r\"\\2\", md, flags=re.DOTALL)\n",
    "\n",
    "    # End-matter collection and rebuild\n",
    "    def _em_items(bs: Dict[str, Any]) -> List[Dict[str, str]]:\n",
    "        items = (bs.get(\"chapter_template\") or {}).get(\"end_matter\") or []\n",
    "        heading_fmt = (bs.get(\"chapter_template\") or {}).get(\"end_matter_heading_format\", \"### {label}\")\n",
    "        out, seen = [], set()\n",
    "        for x in items:\n",
    "            if isinstance(x, dict):\n",
    "                label = (x.get(\"label\") or x.get(\"title\") or str(x)).strip()\n",
    "                heading = x.get(\"heading\") or heading_fmt.format(label=label)\n",
    "            else:\n",
    "                s = str(x); label = s.split(\":\", 1)[0].strip() if \":\" in s else s.strip()\n",
    "                heading = heading_fmt.format(label=label)\n",
    "            if label and label.lower() not in seen:\n",
    "                seen.add(label.lower()); out.append({\"label\": label, \"heading\": heading})\n",
    "        return out\n",
    "\n",
    "    em_items = _em_items(spec)\n",
    "\n",
    "    def collect_heading_blocks(text: str, item: Dict[str, str]) -> List[str]:\n",
    "        h = re.escape(item[\"heading\"])\n",
    "        rx = re.compile(rf\"(?ms)^\\s*{h}\\s*\\n(.*?)(?=^\\s*###\\s+|\\Z)\")\n",
    "        return rx.findall(text)\n",
    "\n",
    "    def collect_label_variants(text: str, items: List[Dict[str, str]]):\n",
    "        labels = [it[\"label\"] for it in items]\n",
    "        label_alt = r\"|\".join([re.escape(l) for l in labels])\n",
    "        start_rx = re.compile(rf\"(?im)^(?:[*_>\\s]*)(?:{label_alt})(?:\\s*[:\\-–—]\\s*)(.*)$\")\n",
    "        hits, lines = [], text.splitlines()\n",
    "        i = 0\n",
    "        while i < len(lines):\n",
    "            m = start_rx.match(lines[i])\n",
    "            if not m: i += 1; continue\n",
    "            line_clean = re.sub(r\"^[*_>\\s]+|[*_\\s:–—-]+$\", \"\", lines[i]).strip().lower()\n",
    "            matched_label = next((lab for lab in sorted(labels, key=len, reverse=True) if line_clean.startswith(lab.lower())), None)\n",
    "            if not matched_label: i += 1; continue\n",
    "            body_lines, inline_rest = [], m.group(1).rstrip()\n",
    "            if inline_rest: body_lines.append(inline_rest)\n",
    "            j = i + 1\n",
    "            while j < len(lines):\n",
    "                ln = lines[j]\n",
    "                if ln.startswith(\"#\") or start_rx.match(ln): break\n",
    "                if ln.strip() == \"\" and j + 1 < len(lines) and lines[j + 1].startswith(\"#\"): break\n",
    "                body_lines.append(ln); j += 1\n",
    "            hits.append((i, j, matched_label, \"\\n\".join(body_lines).strip()))\n",
    "            i = j\n",
    "        return hits\n",
    "\n",
    "    if em_items:\n",
    "        found_blocks: Dict[str, List[str]] = {item[\"heading\"]: [] for item in em_items}\n",
    "        for item in em_items:\n",
    "            for bl in collect_heading_blocks(md, item):\n",
    "                found_blocks[item[\"heading\"]].append(bl)\n",
    "\n",
    "        variants = collect_label_variants(md, em_items)\n",
    "        if variants:\n",
    "            drop = [(a, b) for (a, b, _, _) in variants]\n",
    "            rebuilt, lines = [], md.splitlines()\n",
    "            for idx in range(len(lines)):\n",
    "                if any(a <= idx < b for (a, b) in drop): continue\n",
    "                rebuilt.append(lines[idx])\n",
    "            md = \"\\n\".join(rebuilt)\n",
    "            by_label: Dict[str, List[str]] = {}\n",
    "            for _, _, label_text, body in variants:\n",
    "                if body.strip(): by_label.setdefault(label_text, []).append(body)\n",
    "            label_to_heading = {it[\"label\"]: it[\"heading\"] for it in em_items}\n",
    "            for lab, bodies in by_label.items():\n",
    "                h = label_to_heading.get(lab)\n",
    "                if h: found_blocks.setdefault(h, []).extend(bodies)\n",
    "\n",
    "        # remove canonical headings in body\n",
    "        for item in em_items:\n",
    "            h = re.escape(item[\"heading\"])\n",
    "            md = re.sub(rf\"(?ms)^\\s*{h}\\s*\\n(.*?)(?=^\\s*###\\s+|\\Z)\", \"\", md)\n",
    "\n",
    "        # rebuild EOF block\n",
    "        em_out = []\n",
    "        for item in em_items:\n",
    "            h = item[\"heading\"]\n",
    "            found = found_blocks.get(h, [])\n",
    "            if found:\n",
    "                body = max(found, key=lambda x: len(x.strip())).strip()\n",
    "                if body:\n",
    "                    em_out.append(f\"{h}\\n{body}\\n\")\n",
    "        if em_out:\n",
    "            md = md.rstrip() + \"\\n\\n\" + \"\\n\\n\".join(em_out).rstrip() + \"\\n\"\n",
    "\n",
    "    # Section heading normalization/removal\n",
    "    render_heads = bool(tmpl.get(\"render_section_headings\", False))\n",
    "    number_sections = bool(tmpl.get(\"number_sections\", False))\n",
    "    sec_fmt = tmpl.get(\"section_heading_format\", \"### {title}\")\n",
    "    known_secs = tmpl.get(\"sections\") or ([\"Introduction\",\"Argument\",\"Evidence/Case\",\"Counterpoint\",\"Summary\"]\n",
    "                                          if BOOK.get(\"kind\")==\"nonfiction\" else\n",
    "                                          [\"Opening hook scene\",\"Problem escalation\",\"Twist or revelation\",\"Action or moral dilemma\",\"Cliffhanger or resolution\"])\n",
    "    names_rx = r\"|\".join([re.escape(n) for n in known_secs])\n",
    "    sec_rx = re.compile(rf\"^(#+)\\s+(?:\\d+\\.\\s+)?({names_rx})\\s*$\", re.IGNORECASE | re.MULTILINE)\n",
    "\n",
    "    matches = list(sec_rx.finditer(md))\n",
    "    if matches:\n",
    "        out_text = md\n",
    "        offset, count = 0, 0\n",
    "        for m in matches:\n",
    "            start, end = m.span()\n",
    "            start += offset; end += offset\n",
    "            title = m.group(2).strip()\n",
    "            if not render_heads:\n",
    "                repl = \"\"\n",
    "            else:\n",
    "                count += 1\n",
    "                display_title = f\"{count}. {title}\" if number_sections else title\n",
    "                repl = sec_fmt.format(index=count, title=display_title)\n",
    "            before, after = out_text[:start], out_text[end:]\n",
    "            out_text = before + repl + after\n",
    "            offset += (len(repl) - (end - start))\n",
    "        md = out_text\n",
    "\n",
    "    # Clean whitespace\n",
    "    md = \"\\n\".join([ln.rstrip() for ln in md.splitlines()])\n",
    "    md = re.sub(r\"\\n{3,}\", r\"\\n\\n\", md).strip() + \"\\n\"\n",
    "    return md\n",
    "\n",
    "# ------------------------- Tighten micro-pass helper ------------------------\n",
    "\n",
    "def build_author_tighten_prompt(chapter_md: str, ch: int, oc: Dict[str, Any]) -> str:\n",
    "    \"\"\"\n",
    "    Ask AuthorAgent for a cheap 'tighten' pass:\n",
    "      - improve cadence/clarity/micro-tension (fiction) or argumentative flow (nonfiction)\n",
    "      - remove repetition/filler, keep facts/events intact\n",
    "      - keep exactly one chapter heading at the top (do not change chapter title/number)\n",
    "      - do not invent new facts; preserve continuity\n",
    "    Output ONLY Markdown (no commentary).\n",
    "    \"\"\"\n",
    "    return \"\".join([\n",
    "        \"You are AuthorAgent performing a LIGHT TIGHTEN pass.\\n\",\n",
    "        \"Goals:\\n\",\n",
    "        \"- Improve cadence and flow; vary sentence length; cut filler.\\n\",\n",
    "        \"- Preserve all plot facts (fiction) or claims/logic (nonfiction).\\n\",\n",
    "        \"- Keep exactly one top chapter heading as-is; do NOT alter title/number.\\n\",\n",
    "        \"- Keep section headings policy consistent with the input.\\n\",\n",
    "        \"- No code fences. Output ONLY Markdown.\\n\\n\",\n",
    "        f\"CHAPTER_NUMBER: {ch}\\n\",\n",
    "        f\"CHAPTER_TITLE: {oc.get('title','')}\\n\\n\",\n",
    "        \"INPUT_CHAPTER_MD:\\n\",\n",
    "        chapter_md,\n",
    "    ])\n",
    "\n",
    "# --------------------------- Gate metric calculators ------------------------\n",
    "\n",
    "def _gate_value_shift(router, ch: int, md_final: str, envelope) -> Optional[float]:\n",
    "    \"\"\"Cheap LLM micro-check: estimate fraction of scenes/sections with a clear state A→B change.\"\"\"\n",
    "    try:\n",
    "        q = (\n",
    "            \"Given the chapter Markdown below, estimate the fraction (0..1) of scenes/sections that contain a clear VALUE SHIFT \"\n",
    "            \"(the situation or stakes move from state A to state B). Return ONLY JSON: {\\\"value_shift_pass_rate\\\": float}.\\n\\n\"\n",
    "            + md_final[:25000]\n",
    "        )\n",
    "        R = router.editor.run(\n",
    "            f\"ch{ch}_gate_value_shift\",\n",
    "            q,\n",
    "            stage=\"gate_check\",\n",
    "            max_t=300,\n",
    "            force_json=True,\n",
    "            cache_payload={\"pv\": PL.get(\"PROMPT_VERSIONS\", {}).get(\"editor\",\"v3\"), \"gate\": \"value_shift\", \"ch\": ch},\n",
    "            cache_key=sha1(q),\n",
    "            envelope=envelope,\n",
    "        ) or {}\n",
    "        txt = (R.get(\"text\") or \"\").strip()\n",
    "        data = json.loads(sanitize_md(txt)) if txt else {}\n",
    "        v = float(data.get(\"value_shift_pass_rate\", 0.0))\n",
    "        return max(0.0, min(1.0, v))\n",
    "    except Exception as e:\n",
    "        log_exc(f\"ch{ch}_gate_value_shift\", e)\n",
    "        return None\n",
    "\n",
    "def _gate_hook(router, ch: int, md_final: str, envelope) -> Optional[float]:\n",
    "    \"\"\"Cheap LLM micro-check: score last ~1200 chars for hook/cliffhanger momentum (0..1).\"\"\"\n",
    "    try:\n",
    "        tail = md_final[-1200:]\n",
    "        q = (\n",
    "            \"Score the strength of the CHAPTER END hook/cliffhanger (0..1). A strong hook propels the reader forward with \"\n",
    "            \"tension, an unanswered question, or escalated stakes. Return ONLY JSON: {\\\"hook_score\\\": float}.\\n\\n\"\n",
    "            f\"CHAPTER_END_SNIPPET:\\n{tail}\"\n",
    "        )\n",
    "        R = router.editor.run(\n",
    "            f\"ch{ch}_gate_hook\",\n",
    "            q,\n",
    "            stage=\"gate_check\",\n",
    "            max_t=250,\n",
    "            force_json=True,\n",
    "            cache_payload={\"pv\": PL.get(\"PROMPT_VERSIONS\", {}).get(\"editor\",\"v3\"), \"gate\": \"hook\", \"ch\": ch},\n",
    "            cache_key=sha1(q),\n",
    "            envelope=envelope,\n",
    "        ) or {}\n",
    "        txt = (R.get(\"text\") or \"\").strip()\n",
    "        data = json.loads(sanitize_md(txt)) if txt else {}\n",
    "        v = float(data.get(\"hook_score\", 0.0))\n",
    "        return max(0.0, min(1.0, v))\n",
    "    except Exception as e:\n",
    "        log_exc(f\"ch{ch}_gate_hook\", e)\n",
    "        return None\n",
    "\n",
    "def _gate_claim_coverage(claims: List[Dict[str, Any]], resolved: List[Dict[str, Any]]) -> Optional[float]:\n",
    "    \"\"\"\n",
    "    Heuristic non-LLM check: coverage = fraction of claims with at least one plausible source/summary.\n",
    "    A resolved item counts if it has any of: sources/url/citation/refs length > 0.\n",
    "    \"\"\"\n",
    "    try:\n",
    "        if not claims:\n",
    "            return 1.0\n",
    "        # Build quick lookup by marker or id\n",
    "        def _has_support(it: Dict[str, Any]) -> bool:\n",
    "            if not isinstance(it, dict): return False\n",
    "            # any common support fields\n",
    "            for k in (\"sources\",\"urls\",\"citations\",\"refs\",\"references\"):\n",
    "                v = it.get(k)\n",
    "                if isinstance(v, list) and len(v) > 0:\n",
    "                    return True\n",
    "            for k in (\"url\",\"doi\",\"citation\"):\n",
    "                if it.get(k): return True\n",
    "            # nested\n",
    "            src = it.get(\"source\") or it.get(\"support\")\n",
    "            if isinstance(src, (list, dict)): return True\n",
    "            return False\n",
    "\n",
    "        supported = 0\n",
    "        # Try to align by marker/id if present\n",
    "        by_marker = {}\n",
    "        for r in resolved:\n",
    "            mk = (str(r.get(\"marker\") or \"\")).strip()\n",
    "            if mk: by_marker.setdefault(mk, []).append(r)\n",
    "\n",
    "        for cl in claims:\n",
    "            mk = (str(cl.get(\"marker\") or \"\")).strip()\n",
    "            if mk and mk in by_marker:\n",
    "                if any(_has_support(x) for x in by_marker[mk]):\n",
    "                    supported += 1\n",
    "                continue\n",
    "            # fallback: any resolved has support -> partial credit\n",
    "            if any(_has_support(r) for r in resolved):\n",
    "                supported += 1\n",
    "\n",
    "        return max(0.0, min(1.0, supported / max(1, len(claims))))\n",
    "    except Exception:\n",
    "        return None\n",
    "\n",
    "# ------------------------ Main per-chapter processor ------------------------\n",
    "\n",
    "def process_chapter(ch: int, cfg: Dict[str, Any], tracker, router, pj: str) -> None:\n",
    "    \"\"\"\n",
    "    Two-pass authoring (sectioned draft → LIGHT tighten), strict editor with tagged outputs,\n",
    "    selective research AFTER editor based on claims, and cheap gate metrics.\n",
    "    Writes:\n",
    "      - content/drafts/<ch>/draft.md\n",
    "      - content/drafts/<ch>/draft_tight.md  (new; used as editor input when present)\n",
    "      - content/edits/<ch>/chapter_<ch>.md\n",
    "      - content/edits/<ch>/claims_report.json\n",
    "      - content/edits/<ch>/continuity_notes.md\n",
    "      - content/edits/<ch>/gates.json       (new; gate metrics for this chapter)\n",
    "      - content/research/<ch>/claims_resolved.json (if research enabled & claims)\n",
    "      - logs/chXX_author_raw_*.txt (raw model outputs)\n",
    "    \"\"\"\n",
    "    ch = int(ch)\n",
    "    env_cap = _safe_float(cfg.get(\"CHAPTER_COST_CAP_USD\", PL.get(\"CHAPTER_COST_CAP_USD\", 0.25)), 0.25)\n",
    "    envelope = tracker.child_envelope(f\"ch{ch:02d}\", cap_usd=env_cap) if tracker else None\n",
    "\n",
    "    # Chapter directories\n",
    "    dr = ch_dir(ch, \"research\")\n",
    "    dd = ch_dir(ch, \"drafts\")\n",
    "    de = ch_dir(ch, \"edits\")\n",
    "    _ensure_dir(Path(\"logs\"))\n",
    "\n",
    "    # Paths\n",
    "    dp = dd / \"draft.md\"\n",
    "    dp_tight = dd / \"draft_tight.md\"\n",
    "    meta_path = dd / \".author_call.json\"\n",
    "    ep = de / f\"chapter_{ch:02d}.md\"\n",
    "    decp = de / \"claims_report.json\"\n",
    "    nop = de / \"continuity_notes.md\"\n",
    "    gatep = de / \"gates.json\"\n",
    "\n",
    "    # Outline object\n",
    "    oc = outline_ch(pj, ch)\n",
    "\n",
    "    # ---- Author draft (sectioned) ----\n",
    "    need_author = (not dp.exists()) or (dp.stat().st_size < 8) or (sanitize_md(read_text(dp)).strip() == \"\")\n",
    "    if need_author:\n",
    "        tmpl = BOOK.get(\"chapter_template\", {}) or {}\n",
    "        ch_fmt = tmpl.get(\"chapter_heading_format\", \"## Chapter {number}: {title}\")\n",
    "        render_heads = bool(tmpl.get(\"render_section_headings\", False))\n",
    "        number_sections = bool(tmpl.get(\"number_sections\", False))\n",
    "        sec_fmt = tmpl.get(\"section_heading_format\", \"### {title}\")\n",
    "        sections = tmpl.get(\"sections\") or (\n",
    "            [\"Introduction\",\"Argument\",\"Evidence/Case\",\"Counterpoint\",\"Summary\"] if BOOK.get(\"kind\")==\"nonfiction\" else\n",
    "            [\"Opening hook scene\",\"Problem escalation\",\"Twist or revelation\",\"Action or moral dilemma\",\"Cliffhanger or resolution\"]\n",
    "        )\n",
    "\n",
    "        per, min_w, max_w, per_section = compute_chapter_word_targets(BOOK, cfg, len(sections), ch_num=ch)\n",
    "        tw = max(min_w, min(max_w, per))\n",
    "        per_section = _safe_int(cfg.get(\"AUTHOR_SECTION_TARGET_WORDS\", per_section), per_section)\n",
    "\n",
    "        ctx_chars = _safe_int(cfg.get(\"AUTHOR_CONTEXT_CHARS\", 6000), 6000)\n",
    "\n",
    "        em = end_matter_spec(BOOK)\n",
    "        em_labels = em[\"labels\"]\n",
    "        em_preview = \"\\n\".join([f\"- {item['heading']}\" for item in em_labels]) if em_labels else \"\"\n",
    "        em_label_names = \", \".join([x[\"label\"] for x in em_labels]) if em_labels else \"end-matter items\"\n",
    "\n",
    "        accum: List[str] = []\n",
    "        last_R = None\n",
    "\n",
    "        for idx_sec, sec in enumerate(sections, 1):\n",
    "            sec_title = f\"{idx_sec}. {sec}\" if number_sections else sec\n",
    "            sec_heading = sec_fmt.format(index=idx_sec, title=sec_title)\n",
    "            so_far = \"\\n\".join(accum)[-ctx_chars:] if accum else \"\"\n",
    "            chapter_heading = ch_fmt.format(number=ch, title=oc.get(\"title\", \"\"))\n",
    "\n",
    "            heading_chunk = (\n",
    "                f\"SECTION_HEADING_MD:\\n{sec_heading}\\n\\n\"\n",
    "                \"Begin the section with SECTION_HEADING_MD on its own line, then continue prose.\\n\"\n",
    "                if render_heads else \"\"\n",
    "            )\n",
    "            end_matter_chunk = \"\"\n",
    "            nonfinal_guard = \"\"\n",
    "            if idx_sec == len(sections) and em_labels:\n",
    "                end_matter_chunk = (\n",
    "                    \"\\nAt the very end of THIS FINAL SECTION, append a single end-matter block in this order:\\n\"\n",
    "                    + (em_preview + \"\\n\")\n",
    "                    + \"Do not wrap end-matter in code fences.\\n\"\n",
    "                )\n",
    "            else:\n",
    "                nonfinal_guard = (\n",
    "                    f\"- Do NOT include end-matter here (avoid lines like '{em_label_names}:' or bold/italic variants).\\n\"\n",
    "                )\n",
    "\n",
    "            prompt = \"\".join([\n",
    "                \"You are AuthorAgent. Draft the requested section only.\\n\\n\",\n",
    "                \"BOOK SPEC:\\n\", json.dumps(BOOK, ensure_ascii=False, indent=2), \"\\n\\n\",\n",
    "                \"OUTLINE_JSON:\\n\", json.dumps(oc, ensure_ascii=False, indent=2), \"\\n\\n\",\n",
    "                \"CHAPTER_SO_FAR_MD:\\n\", so_far, \"\\n\\n\",\n",
    "                f\"Write ONLY Markdown for this section of '{chapter_heading}'.\\n\",\n",
    "                f\"Section {idx_sec}/{len(sections)}: {sec}\\n\",\n",
    "                heading_chunk,\n",
    "                \"- Continue seamlessly from CHAPTER_SO_FAR_MD (no recap).\\n\",\n",
    "                \"- Maintain voice, POV, and pack style; short, cinematic paragraphs (or crisp argument flow for nonfiction).\\n\",\n",
    "                f\"- Target ≈ {per_section} words for THIS section.\\n\",\n",
    "                \"- Do not repeat the chapter title; only section text (plus section heading if enabled).\\n\",\n",
    "                \"- If you hit the target but the beat/objective isn’t complete, finish it.\\n\",\n",
    "                \"- Use [n] markers sparingly for verifiable claims.\\n\",\n",
    "                nonfinal_guard,\n",
    "                end_matter_chunk,\n",
    "            ])\n",
    "\n",
    "            last_R = router.author.run(\n",
    "                f\"ch{ch}_draft_s{idx_sec}\",\n",
    "                prompt,\n",
    "                stage=\"author_block\",\n",
    "                cache_payload={\"pv\": PL.get(\"PROMPT_VERSIONS\", {}).get(\"author\",\"v4\"), \"ch\": ch, \"sec\": idx_sec, \"tw\": tw, \"sections\": sections},\n",
    "                cache_key=sha1(json.dumps({\"ch\": ch, \"sec\": idx_sec, \"oc\": oc, \"tw\": tw, \"sections\": sections}, sort_keys=True)),\n",
    "                max_t=_safe_int(cfg.get(\"AUTHOR_MAX_TOKENS\", 3200), 3200),\n",
    "                envelope=envelope,\n",
    "            )\n",
    "\n",
    "            raw_model = (last_R or {}).get(\"text\", \"\") or \"\"\n",
    "            write_text(f\"logs/ch{ch:02d}_author_raw_s{idx_sec}_MODEL.txt\", raw_model)\n",
    "            part = sanitize_md(raw_model)\n",
    "            write_text(f\"logs/ch{ch:02d}_author_raw_s{idx_sec}.txt\", part)\n",
    "            accum.append(part.strip())\n",
    "\n",
    "        chapter_heading = ch_fmt.format(number=ch, title=oc.get(\"title\", \"\"))\n",
    "        full = chapter_heading + \"\\n\\n\" + \"\\n\\n\".join(accum)\n",
    "        write_text(dp, full)\n",
    "\n",
    "        meta = {\n",
    "            \"t\": now_utc_iso(),\n",
    "            \"chapter\": ch,\n",
    "            \"outline_title\": oc.get(\"title\", \"\"),\n",
    "            \"attempt\": \"sectioned\",\n",
    "            \"target_words\": tw,\n",
    "            \"sections\": len(sections),\n",
    "            \"draft_chars\": len(full),\n",
    "            \"draft_words\": len(full.split()),\n",
    "            \"cached\": bool(last_R and last_R.get(\"cached\")),\n",
    "            \"usage\": last_R.get(\"usage\") if last_R else None,\n",
    "            \"est_cost\": last_R.get(\"est_cost\") if last_R else None,\n",
    "        }\n",
    "        write_json(meta_path, meta)\n",
    "    else:\n",
    "        if not meta_path.exists():\n",
    "            write_json(meta_path, {\"t\": now_utc_iso(), \"chapter\": ch, \"note\": \"Draft existed; AuthorAgent not called this run.\"})\n",
    "\n",
    "    # ---- Author TIGHTEN micro-pass (optional, cheap) ----\n",
    "    use_tighten = _bool(PL.get(\"AUTHOR_TIGHTEN_ENABLED\", False), False) and not _bool(PL.get(\"ULTRA_BUDGET_MODE\", False), False)\n",
    "    dp_for_editor = dp\n",
    "    if use_tighten:\n",
    "        try:\n",
    "            draft_text_for_tighten = read_text(dp)\n",
    "            tighten_prompt = build_author_tighten_prompt(draft_text_for_tighten, ch, oc)\n",
    "            Rt = router.author.run(\n",
    "                f\"ch{ch}_tighten\",\n",
    "                tighten_prompt,\n",
    "                stage=\"author_tighten\",\n",
    "                cache_payload={\"pv\": PL.get(\"PROMPT_VERSIONS\", {}).get(\"author\",\"v4\"), \"ch\": ch, \"tighten\": True},\n",
    "                cache_key=sha1(draft_text_for_tighten),\n",
    "                max_t=_safe_int(cfg.get(\"AUTHOR_TIGHTEN_MAX_TOKENS\", 16384), 16384),\n",
    "                envelope=envelope,\n",
    "            ) or {}\n",
    "            tightened = sanitize_md(Rt.get(\"text\") or \"\")\n",
    "            if tightened.strip():\n",
    "                write_text(dp_tight, tightened)\n",
    "                dp_for_editor = dp_tight\n",
    "        except Exception as e:\n",
    "            log_exc(f\"ch{ch}_author_tighten\", e)\n",
    "\n",
    "    # ---- Editor pass (budget-aware) ----\n",
    "    if PL.get(\"ULTRA_BUDGET_MODE\"):\n",
    "        # Minimal path: copy draft and skip edits\n",
    "        write_text(ep, read_text(dp_for_editor))\n",
    "        write_json(decp, {\"info\": \"Editor skipped (ULTRA_BUDGET_MODE)\", \"chapter\": ch})\n",
    "        write_text(nop, \"Local cleanup applied; no LLM edit due to ultra budget mode.\")\n",
    "        print(f\"[ch{ch:02d}] Editor skipped: ULTRA_BUDGET_MODE\")\n",
    "        return\n",
    "\n",
    "    draft_text = read_text(dp_for_editor)\n",
    "    MAX_CHARS = _safe_int(cfg.get(\"EDITOR_MAX_INPUT_CHARS\", PL.get(\"EDITOR_MAX_INPUT_CHARS\", 300_000)), 300_000)\n",
    "    dtext = draft_text[:MAX_CHARS]\n",
    "\n",
    "    # compute word target for editor hints if available from meta\n",
    "    meta = read_json(meta_path) or {}\n",
    "    tw_for_editor = meta.get(\"target_words\")\n",
    "\n",
    "    instr = build_editor_instruction(dtext, {**BOOK, \"current_chapter_number\": ch, \"current_chapter_title\": oc.get(\"title\",\"\")}, oc, ch, cfg, tw=tw_for_editor)\n",
    "\n",
    "    # Guard output tokens relative to context cap (matches Cell 4 logic)\n",
    "    def _tok_est(chars: int) -> int: return max(1, chars // 4)\n",
    "    EDITOR_MODEL = (cfg.get(\"EDITOR_MODEL\") or PL.get(\"MODEL_ID_FAST\",\"gpt-4o\")).lower()\n",
    "    MODEL_CAPS = {\"gpt-4o\": {\"ctx\": 128_000, \"out\": 16_384}, \"gpt-4o-mini\": {\"ctx\": 128_000, \"out\": 8_192}}\n",
    "    caps = MODEL_CAPS.get(EDITOR_MODEL, {\"ctx\": _safe_int(cfg.get(\"EDITOR_CONTEXT_TOKENS\", 128_000), 128_000),\n",
    "                                         \"out\": _safe_int(cfg.get(\"EDITOR_MAX_TOKENS\", 16_384), 16_384)})\n",
    "    ctx_limit = min(_safe_int(cfg.get(\"EDITOR_CONTEXT_TOKENS\", caps[\"ctx\"]), caps[\"ctx\"]), caps[\"ctx\"])\n",
    "    safe_margin = _safe_int(cfg.get(\"EDITOR_SAFE_MARGIN_TOKENS\", 1_000), 1_000)\n",
    "    out_cap = min(_safe_int(cfg.get(\"EDITOR_MAX_TOKENS\", caps[\"out\"]), caps[\"out\"]), caps[\"out\"])\n",
    "    min_out = min(_safe_int(cfg.get(\"EDITOR_MIN_TOKENS\", 16_384), 16_384), out_cap)\n",
    "    est_in_tokens = _tok_est(len(dtext))\n",
    "    desired_out = min(max(0, ctx_limit - est_in_tokens - safe_margin), out_cap)\n",
    "    if desired_out < min_out:\n",
    "        max_in_tokens = max(1, ctx_limit - min_out - safe_margin)\n",
    "        max_in_chars = max_in_tokens * 4\n",
    "        if len(dtext) > max_in_chars:\n",
    "            dtext = dtext[-max_in_chars:]\n",
    "            est_in_tokens = _tok_est(len(dtext))\n",
    "            desired_out = min(max(0, ctx_limit - est_in_tokens - safe_margin), out_cap)\n",
    "    editor_max_t = max(min_out, min(desired_out, out_cap))\n",
    "\n",
    "    try:\n",
    "        Re = router.editor.run(\n",
    "            f\"ch{ch}_edit\",\n",
    "            instr,\n",
    "            stage=\"editor\",\n",
    "            cache_payload={\"pv\": PL.get(\"PROMPT_VERSIONS\", {}).get(\"editor\",\"v3\"), \"ch\": ch},\n",
    "            cache_key=sha1(dtext),\n",
    "            max_t=editor_max_t,\n",
    "            envelope=envelope,\n",
    "        )\n",
    "    except Exception as e:\n",
    "        log_exc(f\"ch{ch}_editor_call\", e)\n",
    "        Re = {}\n",
    "\n",
    "    md, cl, nt = parse_editor_blocks((Re or {}).get(\"text\", \"\") or \"\")\n",
    "    md_final = sanitize_chapter_markdown(md or draft_text, {**BOOK, \"current_chapter_number\": ch, \"current_chapter_title\": oc.get(\"title\",\"\")})\n",
    "\n",
    "    write_text(ep, md_final)\n",
    "    write_json(decp, cl)\n",
    "    write_text(nop, nt)\n",
    "\n",
    "    # ---- Selective Research AFTER editor: resolve claims if enabled ----\n",
    "    claims = (cl or {}).get(\"claims\", []) if isinstance(cl, dict) else []\n",
    "    resolved: List[Dict[str, Any]] = []\n",
    "    if PL.get(\"RESEARCH_ENABLED\", True) and claims and getattr(router, \"research\", None):\n",
    "        research_prompt = json.dumps({\"chapter\": ch, \"claims\": claims}, ensure_ascii=False)\n",
    "        try:\n",
    "            Rr = router.research.run(\n",
    "                f\"ch{ch}_research_claims\",\n",
    "                \"Resolve the following claims. Return a JSON array as specified in your system prompt.\\n\\n\" + research_prompt,\n",
    "                stage=\"research\",\n",
    "                cache_payload={\"pv\": PL.get(\"PROMPT_VERSIONS\", {}).get(\"research\",\"v2\"), \"ch\": ch, \"claims_hash\": sha1(json.dumps(claims, sort_keys=True))},\n",
    "                cache_key=sha1(research_prompt),\n",
    "                max_t=_safe_int(cfg.get(\"RESEARCH_MAX_TOKENS\", 1500), 1500),\n",
    "                force_json=True,\n",
    "                envelope=envelope,\n",
    "            )\n",
    "            research_text = (Rr or {}).get(\"text\", \"\") or \"[]\"\n",
    "        except Exception as e:\n",
    "            log_exc(f\"ch{ch}_research_claims_call\", e)\n",
    "            research_text = \"[]\"\n",
    "\n",
    "        # Parse or store raw\n",
    "        try:\n",
    "            resolved = json.loads(sanitize_md(research_text))\n",
    "            if not isinstance(resolved, list): resolved = [{\"raw\": resolved}]\n",
    "        except Exception:\n",
    "            resolved = [{\"raw\": sanitize_md(research_text)}]\n",
    "\n",
    "        write_json(dr / \"claims_resolved.json\", resolved)\n",
    "\n",
    "    # ---- Gate metrics (cheap, cacheable) ----\n",
    "    gates_enabled = {\n",
    "        \"value_shift\": _bool(PL.get(\"GATE_VALUE_SHIFT\", True), True),\n",
    "        \"hook\":        _bool(PL.get(\"GATE_HOOK\", True), True),\n",
    "        \"claim_ev\":    _bool(PL.get(\"GATE_CLAIM_EVIDENCE\", True), True),\n",
    "    }\n",
    "    metrics: Dict[str, Any] = {\n",
    "        \"chapter\": ch,\n",
    "        \"kind\": BOOK.get(\"kind\"),\n",
    "        \"t\": now_utc_iso(),\n",
    "        \"value_shift_pass_rate\": None,\n",
    "        \"hook_score\": None,\n",
    "        \"claim_coverage_rate\": None,\n",
    "    }\n",
    "\n",
    "    if BOOK.get(\"kind\") == \"fiction\":\n",
    "        if gates_enabled[\"value_shift\"]:\n",
    "            metrics[\"value_shift_pass_rate\"] = _gate_value_shift(router, ch, md_final, envelope)\n",
    "        if gates_enabled[\"hook\"]:\n",
    "            metrics[\"hook_score\"] = _gate_hook(router, ch, md_final, envelope)\n",
    "    else:\n",
    "        # nonfiction\n",
    "        if gates_enabled[\"claim_ev\"]:\n",
    "            # compute on claims & resolved research (if any)\n",
    "            metrics[\"claim_coverage_rate\"] = _gate_claim_coverage(claims, resolved)\n",
    "\n",
    "    write_json(gatep, metrics)\n",
    "    write_json(Path(f\"logs/ch{ch:02d}_gates.json\"), metrics)\n",
    "\n",
    "    print(f\"Chapter processor ready (ch{ch:02d})\")"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": 12,
   "metadata": {},
   "outputs": [
    {
     "name": "stdout",
     "output_type": "stream",
     "text": [
      "Assembly ready (no exports)\n"
     ]
    }
   ],
   "source": [
    "# Cell 8 — Assembly (NO exports) — builds book.md, toc.json, stats.json, and a readable References appendix (nonfiction)\n",
    "\n",
    "from __future__ import annotations\n",
    "from pathlib import Path\n",
    "import json, re\n",
    "from typing import Dict, Any, List, Tuple, Optional\n",
    "\n",
    "# Assumes from previous cells:\n",
    "# - write_text, read_text, write_json, read_json, has_file, now_utc_iso\n",
    "# - count_words (or compute with simple split)\n",
    "# - CONFIG[\"book_spec\"] and/or book_spec; if not present, we’ll coalesce safely.\n",
    "\n",
    "# ----------------------------- helpers -----------------------------\n",
    "\n",
    "def _coalesce_spec(spec: Optional[Dict[str, Any]] = None) -> Dict[str, Any]:\n",
    "    \"\"\"Prefer explicit spec, then CONFIG['book_spec'], then book_spec, else {}.\"\"\"\n",
    "    if spec:\n",
    "        return spec\n",
    "    try:\n",
    "        cfg_spec = (CONFIG or {}).get(\"book_spec\")  # type: ignore[name-defined]\n",
    "        if cfg_spec:\n",
    "            return cfg_spec\n",
    "    except Exception:\n",
    "        pass\n",
    "    try:\n",
    "        return book_spec  # type: ignore[name-defined]\n",
    "    except Exception:\n",
    "        return {}\n",
    "\n",
    "def _count_words_local(s: str) -> int:\n",
    "    try:\n",
    "        return count_words(s)  # if provided elsewhere\n",
    "    except Exception:\n",
    "        return len((s or \"\").split())\n",
    "\n",
    "def _count_unresolved_markers(text: str) -> Dict[str, int]:\n",
    "    \"\"\"\n",
    "    KPI-ish unresolved counts:\n",
    "      - literal [n] markers (selective research placeholders)\n",
    "      - explicit TODO/TK/TBD tokens\n",
    "    \"\"\"\n",
    "    t = text or \"\"\n",
    "    n_markers = len(re.findall(r\"\\[(?:n|N)\\]\", t))\n",
    "    todos = len(re.findall(r\"\\b(?:TODO|TBD|TK|FIXME)\\b\", t))\n",
    "    return {\"bracket_n\": n_markers, \"todo_tokens\": todos, \"total\": n_markers + todos}\n",
    "\n",
    "def _gather_references_nonfiction(spec: Dict[str, Any]) -> List[Dict[str, Any]]:\n",
    "    \"\"\"\n",
    "    Collect citation-like objects for nonfiction:\n",
    "      1) references/knowledge_base.json -> 'citations' array (preferred, already structured)\n",
    "      2) content/research/*/claims_resolved.json -> fallback using 'citations'/'url'/'source' fields\n",
    "    Output is CSL-JSON-ish dicts with at least 'id' and 'title' and/or 'URL'.\n",
    "    \"\"\"\n",
    "    out: List[Dict[str, Any]] = []\n",
    "\n",
    "    kb_path = Path(\"references/knowledge_base.json\")\n",
    "    if kb_path.is_file():\n",
    "        try:\n",
    "            kb = read_json(kb_path) or {}\n",
    "            for c in kb.get(\"citations\", []):\n",
    "                if not isinstance(c, dict):\n",
    "                    continue\n",
    "                cid = str(c.get(\"id\") or c.get(\"key\") or c.get(\"doi\") or c.get(\"url\") or len(out) + 1)\n",
    "                title = c.get(\"title\") or c.get(\"container-title\") or c.get(\"journal\") or c.get(\"note\") or \"Untitled\"\n",
    "                item = {\n",
    "                    \"id\": cid,\n",
    "                    \"type\": c.get(\"type\") or \"article-journal\",\n",
    "                    \"title\": title,\n",
    "                    \"author\": [{\"literal\": a} for a in (c.get(\"authors\") or c.get(\"author\") or [])] if isinstance(c.get(\"authors\") or c.get(\"author\"), list) else None,\n",
    "                    \"issued\": {\"date-parts\": [[int(c.get(\"year\"))]]} if str(c.get(\"year\") or \"\").isdigit() else None,\n",
    "                    \"URL\": c.get(\"url\") or None,\n",
    "                    \"DOI\": c.get(\"doi\") or None,\n",
    "                    \"note\": c.get(\"note\") or None,\n",
    "                }\n",
    "                out.append({k: v for k, v in item.items() if v})\n",
    "        except Exception:\n",
    "            pass\n",
    "\n",
    "    # Fallback: resolve from per-chapter research outputs\n",
    "    if not out:\n",
    "        for p in sorted(Path(\"content/research\").glob(\"*/claims_resolved.json\")):\n",
    "            try:\n",
    "                arr = read_json(p) or []\n",
    "                for it in arr:\n",
    "                    # try richer 'citations' array first\n",
    "                    cits = it.get(\"citations\") if isinstance(it, dict) else None\n",
    "                    if isinstance(cits, list) and cits:\n",
    "                        for c in cits:\n",
    "                            if not isinstance(c, dict): continue\n",
    "                            out.append({\n",
    "                                \"id\": f\"R{len(out)+1}\",\n",
    "                                \"type\": c.get(\"type\") or \"webpage\",\n",
    "                                \"title\": c.get(\"title\") or it.get(\"summary\") or \"Source\",\n",
    "                                \"URL\": c.get(\"url\") or c.get(\"URL\"),\n",
    "                                \"issued\": {\"date-parts\": [[int(c.get(\"year\"))]]} if str(c.get(\"year\") or \"\").isdigit() else None,\n",
    "                                \"note\": c.get(\"note\") or None,\n",
    "                            })\n",
    "                        continue\n",
    "                    # else try a single URL/source field\n",
    "                    url = (it or {}).get(\"url\") or (it or {}).get(\"URL\") or (it or {}).get(\"source\")\n",
    "                    if url:\n",
    "                        out.append({\n",
    "                            \"id\": f\"R{len(out)+1}\",\n",
    "                            \"type\": \"webpage\",\n",
    "                            \"title\": (it.get(\"summary\") or \"Source\"),\n",
    "                            \"URL\": str(url).strip(),\n",
    "                        })\n",
    "            except Exception:\n",
    "                continue\n",
    "\n",
    "    # Dedup by (title, URL)\n",
    "    dedup, seen = [], set()\n",
    "    for it in out:\n",
    "        key = (it.get(\"title\"), it.get(\"URL\"))\n",
    "        if key in seen:\n",
    "            continue\n",
    "        seen.add(key); dedup.append(it)\n",
    "    return dedup\n",
    "\n",
    "def _append_references_md(md: str, refs: List[Dict[str, Any]]) -> str:\n",
    "    if not refs:\n",
    "        return md\n",
    "    lines = [md.rstrip(), \"\", \"## References\", \"\"]\n",
    "    for i, r in enumerate(refs, 1):\n",
    "        title = r.get(\"title\") or r.get(\"container-title\") or \"Untitled\"\n",
    "        author_list = r.get(\"author\") or []\n",
    "        authors = \", \".join(\n",
    "            [\n",
    "                (a.get(\"literal\")\n",
    "                 or \", \".join([x for x in [a.get(\"family\"), a.get(\"given\")] if x]).strip(\", \"))\n",
    "                for a in author_list if isinstance(a, dict)\n",
    "            ]\n",
    "        ) if isinstance(author_list, list) else \"\"\n",
    "        year = \"\"\n",
    "        issued = r.get(\"issued\")\n",
    "        if isinstance(issued, dict):\n",
    "            dps = issued.get(\"date-parts\")\n",
    "            if isinstance(dps, list) and dps and isinstance(dps[0], list) and dps[0]:\n",
    "                year = str(dps[0][0])\n",
    "        url = r.get(\"URL\") or r.get(\"url\") or \"\"\n",
    "        line = f\"- [{i}] {title}\"\n",
    "        if authors: line += f\" — {authors}\"\n",
    "        if year:    line += f\" ({year})\"\n",
    "        if url:     line += f\". {url}\"\n",
    "        lines.append(line)\n",
    "    lines.append(\"\")\n",
    "    return \"\\n\".join(lines)\n",
    "\n",
    "# ----------------------------- main -----------------------------\n",
    "\n",
    "def assemble_book(spec: Optional[Dict[str, Any]] = None) -> Tuple[str, str, str]:\n",
    "    \"\"\"\n",
    "    Build `build/book.md` + `build/toc.json` + `build/stats.json`.\n",
    "    NO exports here. (Exports are handled elsewhere.)\n",
    "    Returns: (book_md_path, toc_json_path, stats_json_path)\n",
    "    \"\"\"\n",
    "    spec = _coalesce_spec(spec)\n",
    "    build_dir = Path(\"build\"); build_dir.mkdir(parents=True, exist_ok=True)\n",
    "\n",
    "    # Front matter\n",
    "    title = str(spec.get(\"title\", \"Untitled\")).strip()\n",
    "    subtitle = str(spec.get(\"subtitle\", \"\") or \"\").strip()\n",
    "    author = str(spec.get(\"author\", \"Unknown Author\")).strip()\n",
    "\n",
    "    fm = [\n",
    "        f\"# {title}\",\n",
    "        f\"## {subtitle}\" if subtitle else \"\",\n",
    "        f\"**Author:** {author}\",\n",
    "        \"\",\n",
    "        \"---\",\n",
    "        \"\",\n",
    "    ]\n",
    "\n",
    "    chs, toc = [], []\n",
    "    n = int(spec.get(\"chapters\", 0) or 0)\n",
    "\n",
    "    total_words = 0\n",
    "    unresolved_total = 0\n",
    "\n",
    "    for ch in range(1, n + 1):\n",
    "        # Prefer edited chapter_<ch>.md; fall back to legacy name or draft\n",
    "        edited_path = Path(f\"content/edits/{ch:02d}/chapter_{ch:02d}.md\")\n",
    "        edited_alt  = Path(f\"content/edits/{ch:02d}/draft_edited.md\")  # legacy\n",
    "        draft_path  = Path(f\"content/drafts/{ch:02d}/draft.md\")\n",
    "\n",
    "        t, src, path_used = \"\", \"missing\", None\n",
    "        if edited_path.exists():\n",
    "            t, src, path_used = read_text(edited_path), \"edit\", edited_path\n",
    "        elif edited_alt.exists():\n",
    "            t, src, path_used = read_text(edited_alt), \"edit\", edited_alt\n",
    "        elif draft_path.exists():\n",
    "            t, src, path_used = read_text(draft_path), \"draft\", draft_path\n",
    "        else:\n",
    "            t = f\"## Chapter {ch}: (missing)\\nTODO\"\n",
    "            src, path_used = \"missing\", None\n",
    "\n",
    "        chs.append(t.strip())\n",
    "\n",
    "        # TOC entry = first H2\n",
    "        tl = next((ln.strip() for ln in t.splitlines() if ln.strip().startswith(\"## \")), f\"## Chapter {ch}\")\n",
    "        toc.append({\"chapter\": ch, \"title_line\": tl, \"source\": src, \"path\": str(path_used) if path_used else None})\n",
    "\n",
    "        total_words += _count_words_local(t)\n",
    "        unresolved_total += _count_unresolved_markers(t)[\"total\"]\n",
    "\n",
    "    # Build book.md\n",
    "    md = \"\\n\\n\".join([x for x in fm if x] + chs).rstrip() + \"\\n\"\n",
    "\n",
    "    # Append References for nonfiction builds (if we have any)\n",
    "    if str(spec.get(\"kind\") or \"\").lower() == \"nonfiction\":\n",
    "        refs = _gather_references_nonfiction(spec)\n",
    "        if refs:\n",
    "            md = _append_references_md(md, refs)\n",
    "\n",
    "    # Write artifacts\n",
    "    book_md = build_dir / \"book.md\"\n",
    "    write_text(book_md.as_posix(), md)\n",
    "    write_json((build_dir / \"toc.json\").as_posix(), toc)\n",
    "\n",
    "    # KPIs / stats\n",
    "    words_per_10k = max(1, total_words) / 10_000.0\n",
    "    unresolved_per_10k = round(unresolved_total / words_per_10k, 2) if words_per_10k else float(unresolved_total)\n",
    "    stats = {\n",
    "        \"ts\": now_utc_iso(),\n",
    "        \"title\": title,\n",
    "        \"chapters_planned\": n,\n",
    "        \"total_words\": total_words,\n",
    "        \"unresolved_placeholders_total\": unresolved_total,\n",
    "        \"unresolved_per_10k_words\": unresolved_per_10k,\n",
    "    }\n",
    "    stats_path = build_dir / \"stats.json\"\n",
    "    write_json(stats_path.as_posix(), stats)\n",
    "\n",
    "    print(\"[assembly] book.md, toc.json, stats.json written (no exports).\")\n",
    "    return book_md.as_posix(), \"build/toc.json\", stats_path.as_posix()\n",
    "\n",
    "\n",
    "print(\"Assembly ready (no exports)\")"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": 13,
   "metadata": {},
   "outputs": [
    {
     "name": "stdout",
     "output_type": "stream",
     "text": [
      "QA ready\n"
     ]
    }
   ],
   "source": [
    "# Cell 9 — QA (gates & hygiene checks; KPI reporting — with gate roll-ups and thresholds)\n",
    "\n",
    "from __future__ import annotations\n",
    "from pathlib import Path\n",
    "import re, json\n",
    "from typing import Dict, Any, List, Optional, Tuple\n",
    "\n",
    "# Assumes from previous cells:\n",
    "# - read_text, write_json, read_json, count_words (or fallback), now_utc_iso\n",
    "\n",
    "# ----------------------------- helpers -----------------------------\n",
    "\n",
    "def _count_words_local(s: str) -> int:\n",
    "    try:\n",
    "        return count_words(s)\n",
    "    except Exception:\n",
    "        return len((s or \"\").split())\n",
    "\n",
    "def _count_unresolved_markers(text: str) -> Dict[str, int]:\n",
    "    t = text or \"\"\n",
    "    n_markers = len(re.findall(r\"\\[(?:n|N)\\]\", t))\n",
    "    todos = len(re.findall(r\"\\b(?:TODO|TBD|TK|FIXME)\\b\", t))\n",
    "    return {\"bracket_n\": n_markers, \"todo_tokens\": todos, \"total\": n_markers + todos}\n",
    "\n",
    "def _strip_codeblocks(s: str) -> str:\n",
    "    out, in_code = [], False\n",
    "    for line in (s or \"\").splitlines():\n",
    "        if line.strip().startswith(\"```\"):\n",
    "            in_code = not in_code\n",
    "            continue\n",
    "        if not in_code:\n",
    "            out.append(line)\n",
    "    return \"\\n\".join(out)\n",
    "\n",
    "def _safe_float(x, default=None) -> Optional[float]:\n",
    "    try:\n",
    "        if x is None: return default\n",
    "        return float(x)\n",
    "    except Exception:\n",
    "        return default\n",
    "\n",
    "def _avg(vals: List[Optional[float]]) -> Optional[float]:\n",
    "    nums = [v for v in vals if isinstance(v, (int, float))]\n",
    "    return (sum(nums) / len(nums)) if nums else None\n",
    "\n",
    "# ----------------------------- main QA -----------------------------\n",
    "\n",
    "def run_qa(spec: Dict[str, Any]):\n",
    "    \"\"\"\n",
    "    Produces dist/qa_report.json with:\n",
    "      - build presence & hygiene checks\n",
    "      - unresolved placeholders KPI (≤ 2 per 10k words)\n",
    "      - gate roll-ups from content/edits/<ch>/gates.json\n",
    "        * fiction: value_shift_pass_rate avg; hook_score avg\n",
    "        * nonfiction: claim_coverage_rate avg\n",
    "      - per-chapter gate snapshots for debugging\n",
    "    \"\"\"\n",
    "    out_dir = Path(\"dist\")\n",
    "    out_dir.mkdir(parents=True, exist_ok=True)\n",
    "\n",
    "    rpt: Dict[str, Any] = {\"checks\": {}, \"warnings\": [], \"metrics\": {}, \"ts\": now_utc_iso(), \"gate_metrics\": {}}\n",
    "\n",
    "    book_path = Path(\"build/book.md\")\n",
    "    ok = book_path.exists() and book_path.stat().st_size > 0\n",
    "    rpt[\"checks\"][\"book_exists\"] = bool(ok)\n",
    "    if not ok:\n",
    "        write_json(str(out_dir / \"qa_report.json\"), rpt)\n",
    "        return rpt\n",
    "\n",
    "    t = read_text(book_path)\n",
    "    wc = _count_words_local(t)\n",
    "    rpt[\"metrics\"][\"word_count\"] = wc\n",
    "\n",
    "    # Target window ±10%\n",
    "    tgt = int(spec.get(\"target_length_words\", 20000))\n",
    "    low, hi = int(tgt * 0.9), int(tgt * 1.1)\n",
    "    within = low <= wc <= hi\n",
    "    rpt[\"checks\"][\"word_count_ok\"] = bool(within)\n",
    "    if not within:\n",
    "        rpt[\"warnings\"].append(f\"Word count {wc} vs target {tgt} (acceptable range {low}-{hi}).\")\n",
    "\n",
    "    # Chapters present\n",
    "    n = int(spec.get(\"chapters\", 0) or 0)\n",
    "    toc_path = Path(\"build/toc.json\")\n",
    "    missing_list = []\n",
    "    if toc_path.exists():\n",
    "        try:\n",
    "            toc = read_json(toc_path) or []\n",
    "            all_present = (len(toc) == n) and all(item.get(\"source\", \"unknown\") != \"missing\" for item in toc)\n",
    "            if not all_present and toc:\n",
    "                for idx, item in enumerate(toc, start=1):\n",
    "                    if item.get(\"source\", \"unknown\") == \"missing\":\n",
    "                        missing_list.append(idx)\n",
    "            rpt[\"checks\"][\"all_chapters_present\"] = bool(all_present)\n",
    "        except Exception:\n",
    "            rpt[\"checks\"][\"all_chapters_present\"] = False\n",
    "    else:\n",
    "        # Heuristic via H2 count\n",
    "        h2_count = sum(1 for ln in t.splitlines() if ln.strip().startswith(\"## \"))\n",
    "        rpt[\"checks\"][\"all_chapters_present\"] = h2_count >= n\n",
    "        if h2_count < n:\n",
    "            rpt[\"warnings\"].append(f\"Expected {n} chapters but found {h2_count} H2 headings.\")\n",
    "\n",
    "    if missing_list:\n",
    "        rpt[\"warnings\"].append(f\"Chapters marked missing in TOC: {missing_list}\")\n",
    "\n",
    "    # Per-chapter heading sanity: must start with \"## Chapter X:\"\n",
    "    bad_headings = []\n",
    "    for ch in range(1, n + 1):\n",
    "        block = re.findall(\n",
    "            rf\"^##\\s+Chapter\\s+{ch}:[\\s\\S]*?(?=^##\\s+Chapter\\s+{ch + 1}:|\\Z)\",\n",
    "            t, flags=re.M\n",
    "        )\n",
    "        if not block:\n",
    "            bad_headings.append(ch)\n",
    "    if bad_headings:\n",
    "        rpt[\"warnings\"].append(f\"Chapters missing canonical heading format '## Chapter N: ...': {bad_headings}\")\n",
    "\n",
    "    # Hygiene checks\n",
    "    text_no_code = _strip_codeblocks(t)\n",
    "    if \"####\" in text_no_code:\n",
    "        rpt[\"checks\"][\"heading_levels_ok\"] = False\n",
    "        rpt[\"warnings\"].append(\"Headings exceed H3 (#### found).\")\n",
    "    else:\n",
    "        rpt[\"checks\"][\"heading_levels_ok\"] = True\n",
    "\n",
    "    if re.search(r\"[^\\S\\r\\n]{2,}\", t):\n",
    "        rpt[\"warnings\"].append(\"Double spaces detected.\")\n",
    "    if re.search(r\"[ \\t]+$\", t, flags=re.M):\n",
    "        rpt[\"warnings\"].append(\"Trailing spaces at line ends.\")\n",
    "\n",
    "    # Heuristic: flag long double-quoted passages without any [n] nearby (nonfiction only)\n",
    "    if str(spec.get(\"kind\",\"\")).lower() == \"nonfiction\":\n",
    "        lines = text_no_code.splitlines()\n",
    "        long_quote_issues = []\n",
    "        for i, ln in enumerate(lines, 1):\n",
    "            # look for long quoted substrings (≥ 20 chars between quotes)\n",
    "            for m in re.finditer(r\"“([^”]{20,})”|\\\"([^\\\"]{20,})\\\"\", ln):\n",
    "                window = \"\\n\".join(lines[max(0, i-1):min(len(lines), i+2)])\n",
    "                if \"[n]\" not in window:\n",
    "                    long_quote_issues.append(i)\n",
    "                    break\n",
    "        if long_quote_issues:\n",
    "            rpt[\"warnings\"].append(f\"Potential uncited long quotes on lines: {long_quote_issues[:20]}{'…' if len(long_quote_issues)>20 else ''}\")\n",
    "\n",
    "    # Unresolved markers KPI\n",
    "    unresolved = _count_unresolved_markers(t)\n",
    "    rpt[\"metrics\"][\"unresolved_total\"] = unresolved[\"total\"]\n",
    "    words_per_10k = max(1, wc) / 10_000.0\n",
    "    unresolved_per_10k = round(unresolved[\"total\"] / words_per_10k, 2)\n",
    "    rpt[\"metrics\"][\"unresolved_per_10k_words\"] = unresolved_per_10k\n",
    "    # KPI threshold from blueprint: ≤2 unresolved per 10k words\n",
    "    unresolved_kpi_ok = unresolved_per_10k <= 2.0\n",
    "    rpt[\"checks\"][\"unresolved_kpi_ok\"] = bool(unresolved_kpi_ok)\n",
    "    if not unresolved_kpi_ok:\n",
    "        rpt[\"warnings\"].append(f\"Unresolved placeholders {unresolved_per_10k}/10k words (limit ≤ 2).\")\n",
    "\n",
    "    # ---------------- Gate roll-ups (from per-chapter gates.json) ----------------\n",
    "    # Defaults from blueprint:\n",
    "    fiction_thresholds  = {\"value_shift\": 0.95, \"hook\": 0.90}\n",
    "    nonfiction_thresholds = {\"claim_coverage\": 0.95}\n",
    "\n",
    "    per_ch_gates: List[Dict[str, Any]] = []\n",
    "    vs_vals: List[Optional[float]] = []\n",
    "    hk_vals: List[Optional[float]] = []\n",
    "    cc_vals: List[Optional[float]] = []\n",
    "\n",
    "    for ch in range(1, n + 1):\n",
    "        gp = Path(f\"content/edits/{ch:02d}/gates.json\")\n",
    "        if not gp.exists():\n",
    "            per_ch_gates.append({\"chapter\": ch, \"missing\": True})\n",
    "            continue\n",
    "        g = read_json(gp) or {}\n",
    "        entry = {\"chapter\": ch}\n",
    "        vs = _safe_float(g.get(\"value_shift_pass_rate\"))\n",
    "        hk = _safe_float(g.get(\"hook_score\"))\n",
    "        cc = _safe_float(g.get(\"claim_coverage_rate\"))\n",
    "        if vs is not None:\n",
    "            entry[\"value_shift_pass_rate\"] = round(vs, 4)\n",
    "            vs_vals.append(vs)\n",
    "        if hk is not None:\n",
    "            entry[\"hook_score\"] = round(hk, 4)\n",
    "            hk_vals.append(hk)\n",
    "        if cc is not None:\n",
    "            entry[\"claim_coverage_rate\"] = round(cc, 4)\n",
    "            cc_vals.append(cc)\n",
    "        per_ch_gates.append(entry)\n",
    "\n",
    "    kind = str(spec.get(\"kind\") or \"\").lower()\n",
    "    rpt[\"gate_metrics\"][\"per_chapter\"] = per_ch_gates\n",
    "\n",
    "    if kind == \"fiction\":\n",
    "        vs_avg = _avg(vs_vals)\n",
    "        hk_avg = _avg(hk_vals)\n",
    "        rpt[\"gate_metrics\"][\"rollup\"] = {\n",
    "            \"value_shift_avg\": round(vs_avg, 4) if vs_avg is not None else None,\n",
    "            \"hook_score_avg\": round(hk_avg, 4) if hk_avg is not None else None,\n",
    "            \"targets\": fiction_thresholds,\n",
    "        }\n",
    "        # Pass/Fail against targets (only if at least one value exists)\n",
    "        if vs_avg is not None:\n",
    "            vs_ok = vs_avg >= fiction_thresholds[\"value_shift\"]\n",
    "            rpt[\"checks\"][\"kpi_value_shift_ok\"] = bool(vs_ok)\n",
    "            if not vs_ok:\n",
    "                rpt[\"warnings\"].append(f\"Value-Shift KPI avg {vs_avg:.2%} < target {fiction_thresholds['value_shift']:.0%}.\")\n",
    "        if hk_avg is not None:\n",
    "            hk_ok = hk_avg >= fiction_thresholds[\"hook\"]\n",
    "            rpt[\"checks\"][\"kpi_hook_ok\"] = bool(hk_ok)\n",
    "            if not hk_ok:\n",
    "                rpt[\"warnings\"].append(f\"Hook/Cliffhanger KPI avg {hk_avg:.2%} < target {fiction_thresholds['hook']:.0%}.\")\n",
    "    else:\n",
    "        cc_avg = _avg(cc_vals)\n",
    "        rpt[\"gate_metrics\"][\"rollup\"] = {\n",
    "            \"claim_coverage_avg\": round(cc_avg, 4) if cc_avg is not None else None,\n",
    "            \"targets\": nonfiction_thresholds,\n",
    "        }\n",
    "        if cc_avg is not None:\n",
    "            cc_ok = cc_avg >= nonfiction_thresholds[\"claim_coverage\"]\n",
    "            rpt[\"checks\"][\"kpi_claim_coverage_ok\"] = bool(cc_ok)\n",
    "            if not cc_ok:\n",
    "                rpt[\"warnings\"].append(f\"Claim-Evidence KPI avg {cc_avg:.2%} < target {nonfiction_thresholds['claim_coverage']:.0%}.\")\n",
    "\n",
    "    # Research / citations sanity for nonfiction (light)\n",
    "    research_enabled = bool(\n",
    "        (isinstance(spec.get(\"research_policy\"), dict) and spec[\"research_policy\"].get(\"enabled\")) or\n",
    "        (globals().get(\"pipeline_config\", {}) or {}).get(\"RESEARCH_ENABLED\") or\n",
    "        True  # default true in our pipeline\n",
    "    )\n",
    "    if kind == \"nonfiction\" and research_enabled:\n",
    "        has_markers = \"[n]\" in t\n",
    "        rpt[\"checks\"][\"citations_present_if_research\"] = bool(has_markers)\n",
    "        if not has_markers:\n",
    "            rpt[\"warnings\"].append(\"No [n] markers while research is enabled for nonfiction.\")\n",
    "\n",
    "    # End-matter presence per chapter if defined\n",
    "    end_matter = (spec.get(\"chapter_template\", {}) or {}).get(\"end_matter\", []) or []\n",
    "    if end_matter:\n",
    "        missing_em = []\n",
    "        for ch in range(1, n + 1):\n",
    "            block = re.findall(\n",
    "                rf\"^##\\s+Chapter\\s+{ch}:[\\s\\S]*?(?=^##\\s+Chapter\\s+{ch + 1}:|\\Z)\",\n",
    "                t, flags=re.M\n",
    "            )\n",
    "            if block:\n",
    "                chapter_md = block[0]\n",
    "                # Require that each configured label appears somewhere\n",
    "                missing = []\n",
    "                for label in end_matter:\n",
    "                    lab = label.get(\"label\") if isinstance(label, dict) else str(label).split(\":\",1)[0]\n",
    "                    if lab and lab not in chapter_md:\n",
    "                        missing.append(lab)\n",
    "                if missing:\n",
    "                    missing_em.append({\"chapter\": ch, \"missing\": missing})\n",
    "        if missing_em:\n",
    "            rpt[\"warnings\"].append(f\"End-matter missing in some chapters: {missing_em}\")\n",
    "\n",
    "    # Outcome\n",
    "    rpt[\"outcome\"] = \"PASS\" if not rpt[\"warnings\"] else \"PASS_WITH_WARNINGS\"\n",
    "\n",
    "    write_json(str(out_dir / \"qa_report.json\"), rpt)\n",
    "    print(\"[qa] Report written to dist/qa_report.json\")\n",
    "    return rpt\n",
    "\n",
    "\n",
    "print(\"QA ready\")"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": 14,
   "metadata": {},
   "outputs": [
    {
     "name": "stdout",
     "output_type": "stream",
     "text": [
      "Export ready\n"
     ]
    }
   ],
   "source": [
    "# Cell 10 — Export (Pandoc with citeproc/CSL, richer logs; copies build/book.md to dist/)\n",
    "\n",
    "from __future__ import annotations\n",
    "from pathlib import Path\n",
    "import json, re, shutil, subprocess, zipfile\n",
    "from typing import Dict, Any, List, Tuple, Optional\n",
    "\n",
    "# Assumes from previous cells:\n",
    "# - read_text, write_text, read_json, write_json\n",
    "# - assemble_book has already produced build/book.md\n",
    "# - spec['export'] flags (docx/epub/pdf)\n",
    "\n",
    "def minimal_docx(p, note=\"DOCX export unavailable; see dist/book.md\"):\n",
    "    p = Path(p)\n",
    "    p.parent.mkdir(parents=True, exist_ok=True)\n",
    "    safe = note.replace(\"&\", \"&amp;\").replace(\"<\", \"&lt;\").replace(\">\", \"&gt;\")\n",
    "    with zipfile.ZipFile(p, \"w\", compression=zipfile.ZIP_DEFLATED) as z:\n",
    "        z.writestr(\n",
    "            \"[Content_Types].xml\",\n",
    "            '<?xml version=\"1.0\" encoding=\"UTF-8\" standalone=\"yes\"?>'\n",
    "            '<Types xmlns=\"http://schemas.openxmlformats.org/package/2006/content-types\">'\n",
    "            '<Default Extension=\"rels\" ContentType=\"application/vnd.openxmlformats-package.relationships+xml\"/>'\n",
    "            '<Default Extension=\"xml\" ContentType=\"application/xml\"/>'\n",
    "            '<Override PartName=\"/word/document.xml\" ContentType=\"application/vnd.openxmlformats-officedocument.wordprocessingml.document.main+xml\"/>'\n",
    "            \"</Types>\",\n",
    "        )\n",
    "        z.writestr(\n",
    "            \"_rels/.rels\",\n",
    "            '<?xml version=\"1.0\" encoding=\"UTF-8\" standalone=\"yes\"?>'\n",
    "            '<Relationships xmlns=\"http://schemas.openxmlformats.org/officeDocument/2006/relationships\">'\n",
    "            '<Relationship Id=\"rId1\" Type=\"http://schemas.openxmlformats.org/officeDocument/2006/relationships/officeDocument\" Target=\"word/document.xml\"/>'\n",
    "            \"</Relationships>\",\n",
    "        )\n",
    "        z.writestr(\n",
    "            \"word/document.xml\",\n",
    "            '<?xml version=\"1.0\" encoding=\"UTF-8\" standalone=\"yes\"?>'\n",
    "            '<w:document xmlns:w=\"http://schemas.openxmlformats.org/wordprocessingml/2006/main\">'\n",
    "            \"<w:body><w:p><w:r><w:t>\"\n",
    "            + safe\n",
    "            + \"</w:t></w:r></w:p></w:body></w:document>\",\n",
    "        )\n",
    "\n",
    "def _pandoc_path() -> Optional[str]:\n",
    "    return shutil.which(\"pandoc\")\n",
    "\n",
    "def _pandoc_version(pandoc: Optional[str]) -> Optional[str]:\n",
    "    if not pandoc:\n",
    "        return None\n",
    "    try:\n",
    "        p = subprocess.run([pandoc, \"--version\"], stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True)\n",
    "        return (p.stdout.splitlines() or [\"\"])[0].strip()\n",
    "    except Exception:\n",
    "        return None\n",
    "\n",
    "def _run(cmd: List[str]) -> Tuple[int, str, str]:\n",
    "    try:\n",
    "        p = subprocess.run(cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True, check=False)\n",
    "        return p.returncode, p.stdout, p.stderr\n",
    "    except Exception as e:\n",
    "        return -1, \"\", str(e)\n",
    "\n",
    "def _coalesce_spec(spec: Dict[str, Any] | None = None) -> Dict[str, Any]:\n",
    "    if spec: return spec\n",
    "    try:\n",
    "        cfg_spec = (CONFIG or {}).get(\"book_spec\")  # type: ignore[name-defined]\n",
    "        if cfg_spec: return cfg_spec\n",
    "    except Exception:\n",
    "        pass\n",
    "    try:\n",
    "        return book_spec  # type: ignore[name-defined]\n",
    "    except Exception:\n",
    "        return {}\n",
    "\n",
    "def _gather_refs_for_export(spec: Dict[str, Any]) -> List[Dict[str, Any]]:\n",
    "    refs: List[Dict[str, Any]] = []\n",
    "    kb = Path(\"references/knowledge_base.json\")\n",
    "    if kb.is_file():\n",
    "        try:\n",
    "            data = read_json(kb) or {}\n",
    "            refs = data.get(\"citations\", []) or []\n",
    "        except Exception:\n",
    "            refs = []\n",
    "    if not refs:\n",
    "        for p in sorted(Path(\"content/research\").glob(\"*/claims_resolved.json\")):\n",
    "            try:\n",
    "                arr = read_json(p) or []\n",
    "                for it in arr:\n",
    "                    # Prefer structured citations if present\n",
    "                    cits = (it or {}).get(\"citations\")\n",
    "                    if isinstance(cits, list) and cits:\n",
    "                        refs.extend([c for c in cits if isinstance(c, dict)])\n",
    "                        continue\n",
    "                    # Fallback to a single URL/source field\n",
    "                    url = (it or {}).get(\"url\") or (it or {}).get(\"URL\") or (it or {}).get(\"source\")\n",
    "                    if url:\n",
    "                        refs.append({\"id\": f\"R{len(refs)+1}\", \"type\": \"webpage\", \"title\": (it.get(\"summary\") or \"Source\"), \"URL\": str(url).strip()})\n",
    "            except Exception:\n",
    "                continue\n",
    "    # Dedup simple (title, URL)\n",
    "    dedup, seen = [], set()\n",
    "    for it in refs:\n",
    "        key = (it.get(\"title\"), it.get(\"URL\"))\n",
    "        if key in seen: continue\n",
    "        seen.add(key); dedup.append(it)\n",
    "    return dedup\n",
    "\n",
    "def _detect_pdf_engine() -> Optional[str]:\n",
    "    for cand in (\"tectonic\", \"xelatex\", \"pdflatex\", \"wkhtmltopdf\"):\n",
    "        if shutil.which(cand):\n",
    "            return cand\n",
    "    return None\n",
    "\n",
    "def export_deliverables(spec: Dict[str, Any] | None = None) -> Dict[str, Any]:\n",
    "    \"\"\"\n",
    "    Copy build/book.md to dist/, then try exporting via Pandoc to DOCX/EPUB/PDF\n",
    "    depending on spec['export'] flags. If Pandoc is missing, produce a minimal\n",
    "    DOCX placeholder so deliverables always exist.\n",
    "\n",
    "    Returns a dict with paths, commands, and logs.\n",
    "    \"\"\"\n",
    "    spec = _coalesce_spec(spec)\n",
    "    dist = Path(\"dist\"); dist.mkdir(parents=True, exist_ok=True)\n",
    "    build_md = Path(\"build/book.md\")\n",
    "\n",
    "    results: Dict[str, Any] = {\n",
    "        \"book_md\": None, \"docx\": None, \"epub\": None, \"pdf\": None,\n",
    "        \"log\": [], \"commands\": [], \"pandoc_version\": None,\n",
    "        \"bibliography\": None, \"csl\": None, \"flags\": spec.get(\"export\") or {\"docx\": True, \"epub\": True, \"pdf\": False}\n",
    "    }\n",
    "\n",
    "    # Copy/prepare book.md\n",
    "    if build_md.exists():\n",
    "        shutil.copyfile(build_md, dist / \"book.md\")\n",
    "        results[\"book_md\"] = (dist / \"book.md\").as_posix()\n",
    "        md = read_text(build_md)\n",
    "    else:\n",
    "        md = \"# Book (missing)\\n\"\n",
    "        write_text((dist / \"book.md\").as_posix(), md)\n",
    "        results[\"book_md\"] = (dist / \"book.md\").as_posix()\n",
    "        results[\"log\"].append(\"build/book.md missing; wrote placeholder.\")\n",
    "\n",
    "    # Pandoc path & version\n",
    "    pandoc = _pandoc_path()\n",
    "    results[\"pandoc_version\"] = _pandoc_version(pandoc)\n",
    "    flags = results[\"flags\"]\n",
    "\n",
    "    if not pandoc:\n",
    "        results[\"log\"].append(\"pandoc not found; skipping rich exports.\")\n",
    "        # Minimal DOCX placeholder so users still get a .docx\n",
    "        try:\n",
    "            minimal_docx(\"dist/book.docx\", note=\"Pandoc unavailable. Open dist/book.md.\")\n",
    "            results[\"docx\"] = \"dist/book.docx\"\n",
    "        except Exception as e:\n",
    "            results[\"log\"].append(f\"Failed to create minimal DOCX: {e}\")\n",
    "        write_json(\"build/export_log.json\", results)\n",
    "        print(\"[export] Minimal artifacts written (no pandoc).\")\n",
    "        return results\n",
    "\n",
    "    # Prepare refs (nonfiction and/or citation_style != 'none')\n",
    "    citation_style = str(spec.get(\"citation_style\", \"none\")).lower()\n",
    "    refs_json = None\n",
    "    if str(spec.get(\"kind\") or \"\").lower() == \"nonfiction\" or citation_style != \"none\":\n",
    "        refs = _gather_refs_for_export(spec)\n",
    "        if refs:\n",
    "            refs_dir = Path(\"references\"); refs_dir.mkdir(parents=True, exist_ok=True)\n",
    "            refs_json = refs_dir / \"citations.csl.json\"\n",
    "            try:\n",
    "                write_json(refs_json.as_posix(), refs)\n",
    "                results[\"bibliography\"] = refs_json.as_posix()\n",
    "            except Exception as e:\n",
    "                results[\"log\"].append(f\"Failed writing citations.csl.json: {e}\")\n",
    "                refs_json = None\n",
    "\n",
    "    # Meta YAML\n",
    "    meta_yaml = Path(\"build/_meta.yml\")\n",
    "    try:\n",
    "        meta_yaml.write_text(\n",
    "            \"\\n\".join([\n",
    "                f'title: \"{spec.get(\"title\",\"Untitled\")}\"',\n",
    "                f'subtitle: \"{spec.get(\"subtitle\",\"\")}\"' if spec.get(\"subtitle\") else \"\",\n",
    "                f'author: \"{spec.get(\"author\",\"Unknown Author\")}\"',\n",
    "                'lang: \"en-US\"',\n",
    "            ]) + \"\\n\",\n",
    "            encoding=\"utf-8\",\n",
    "        )\n",
    "    except Exception as e:\n",
    "        results[\"log\"].append(f\"Failed to write meta YAML: {e}\")\n",
    "\n",
    "    # Build base command (+ citeproc/CSL if available)\n",
    "    cite_args: List[str] = []\n",
    "    if refs_json:\n",
    "        cite_args = [\"--citeproc\", f\"--bibliography={refs_json.as_posix()}\"]\n",
    "        # Optional CSL: use spec.citation_style if a matching file exists, else pick any known CSL present\n",
    "        style_map = {\n",
    "            \"apa\": \"references/apa.csl\",\n",
    "            \"chicago\": \"references/chicago-author-date.csl\",\n",
    "            \"ieee\": \"references/ieee.csl\",\n",
    "        }\n",
    "        # preferred style path (if present)\n",
    "        preferred = style_map.get(citation_style)\n",
    "        csl_path = None\n",
    "        if preferred and Path(preferred).is_file():\n",
    "            csl_path = preferred\n",
    "        else:\n",
    "            for alt in [\"references/style.csl\", \"references/apa.csl\", \"references/chicago-author-date.csl\", \"references/ieee.csl\"]:\n",
    "                if Path(alt).is_file():\n",
    "                    csl_path = alt\n",
    "                    break\n",
    "        if csl_path:\n",
    "            cite_args += [f\"--csl={csl_path}\"]\n",
    "            results[\"csl\"] = csl_path\n",
    "\n",
    "    base_cmd = [pandoc, (dist / \"book.md\").as_posix(), \"-f\", \"markdown\", \"--metadata-file\", meta_yaml.as_posix()]\n",
    "    if cite_args:\n",
    "        base_cmd += cite_args\n",
    "\n",
    "    # Optional docx reference template (keeps heading styles consistent if present)\n",
    "    docx_ref = \"references/reference.docx\" if Path(\"references/reference.docx\").is_file() else None\n",
    "\n",
    "    # DOCX\n",
    "    if flags.get(\"docx\", True):\n",
    "        out = dist / \"book.docx\"\n",
    "        cmd = base_cmd + ([\"--reference-doc\", docx_ref] if docx_ref else []) + [\"-o\", out.as_posix()]\n",
    "        results[\"commands\"].append({\"format\": \"docx\", \"cmd\": cmd})\n",
    "        code, out_s, err_s = _run(cmd)\n",
    "        if code == 0:\n",
    "            results[\"docx\"] = out.as_posix()\n",
    "        else:\n",
    "            results[\"log\"].append(f\"DOCX export failed: {err_s.strip()[:500]}\")\n",
    "            try:\n",
    "                minimal_docx(out.as_posix(), note=\"Pandoc failed; open dist/book.md\")\n",
    "                results[\"docx\"] = out.as_posix()\n",
    "            except Exception as e:\n",
    "                results[\"log\"].append(f\"Minimal DOCX creation failed: {e}\")\n",
    "\n",
    "    # EPUB\n",
    "    if flags.get(\"epub\", True):\n",
    "        out = dist / \"book.epub\"\n",
    "        cmd = base_cmd + [\"--toc\", \"-o\", out.as_posix()]\n",
    "        results[\"commands\"].append({\"format\": \"epub\", \"cmd\": cmd})\n",
    "        code, out_s, err_s = _run(cmd)\n",
    "        if code == 0:\n",
    "            results[\"epub\"] = out.as_posix()\n",
    "        else:\n",
    "            results[\"log\"].append(f\"EPUB export failed: {err_s.strip()[:500]}\")\n",
    "\n",
    "    # PDF (requires LaTeX engine)\n",
    "    if flags.get(\"pdf\", False):\n",
    "        out = dist / \"book.pdf\"\n",
    "        pdf_engine = _detect_pdf_engine()\n",
    "        cmd = base_cmd + ([\"--pdf-engine\", pdf_engine] if pdf_engine and pdf_engine != \"wkhtmltopdf\" else []) + [\"-o\", out.as_posix()]\n",
    "        results[\"commands\"].append({\"format\": \"pdf\", \"cmd\": cmd, \"pdf_engine\": pdf_engine})\n",
    "        code, out_s, err_s = _run(cmd)\n",
    "        if code == 0:\n",
    "            results[\"pdf\"] = out.as_posix()\n",
    "        else:\n",
    "            if not pdf_engine:\n",
    "                results[\"log\"].append(\"PDF export failed: no LaTeX engine detected. Install tectonic/xelatex.\")\n",
    "            else:\n",
    "                results[\"log\"].append(f\"PDF export failed with engine '{pdf_engine}'. Error: {err_s.strip()[:500]}\")\n",
    "\n",
    "    write_json(\"build/export_log.json\", results)\n",
    "    print(\"[export] Export results written:\", {k: v for k, v in results.items() if k in (\"docx\",\"epub\",\"pdf\") and v})\n",
    "    return results\n",
    "\n",
    "print(\"Export ready\")"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": 15,
   "metadata": {},
   "outputs": [
    {
     "name": "stdout",
     "output_type": "stream",
     "text": [
      "Manifest ready\n"
     ]
    }
   ],
   "source": [
    "# Cell 11 — Manifest (enriched with stats, QA, exports, gate KPIs, and log tails)\n",
    "\n",
    "from pathlib import Path\n",
    "from datetime import datetime, timezone\n",
    "import json\n",
    "\n",
    "def _scan_files(root_dir: str):\n",
    "    root = Path(root_dir)\n",
    "    items = []\n",
    "    if not root.exists():\n",
    "        return items\n",
    "    for p in sorted(root.rglob(\"*\")):\n",
    "        if p.is_file():\n",
    "            st = p.stat()\n",
    "            items.append(\n",
    "                {\n",
    "                    \"path\": p.as_posix(),\n",
    "                    \"size_bytes\": st.st_size,\n",
    "                    \"mtime\": datetime.fromtimestamp(st.st_mtime, timezone.utc)\n",
    "                    .isoformat()\n",
    "                    .replace(\"+00:00\", \"Z\"),\n",
    "                }\n",
    "            )\n",
    "    return items\n",
    "\n",
    "def _tail_text(path: str, n: int = 50) -> list[str]:\n",
    "    p = Path(path)\n",
    "    if not p.exists():\n",
    "        return []\n",
    "    lines = p.read_text(encoding=\"utf-8\", errors=\"ignore\").splitlines()\n",
    "    return lines[-n:]\n",
    "\n",
    "def manifest(tr, outcome: str):\n",
    "    Path(\"dist\").mkdir(parents=True, exist_ok=True)\n",
    "\n",
    "    # Pull in build/qa/export summaries if present\n",
    "    stats = read_json(\"build/stats.json\") or {}\n",
    "    qa = read_json(\"dist/qa_report.json\") or {}\n",
    "    export_log = read_json(\"build/export_log.json\") or {}\n",
    "    run_manifest = read_json(\"logs/run_manifest.json\") or {}\n",
    "\n",
    "    # Lift key QA metrics (with defaults if absent)\n",
    "    qa_checks = qa.get(\"checks\", {})\n",
    "    qa_metrics = qa.get(\"metrics\", {})\n",
    "    gate_roll = (qa.get(\"gate_metrics\") or {}).get(\"rollup\") or {}\n",
    "    gate_per_ch = (qa.get(\"gate_metrics\") or {}).get(\"per_chapter\") or []\n",
    "\n",
    "    m = {\n",
    "        \"outcome\": outcome,\n",
    "        \"generated_at\": datetime.now(timezone.utc).isoformat().replace(\"+00:00\", \"Z\"),\n",
    "        \"files\": {\n",
    "            \"build\": _scan_files(\"build\"),\n",
    "            \"dist\": _scan_files(\"dist\"),\n",
    "        },\n",
    "        \"cost_summary\": (tr.summary() if hasattr(tr, \"summary\") else None),\n",
    "        \"stats\": {\n",
    "            \"title\": stats.get(\"title\"),\n",
    "            \"chapters_planned\": stats.get(\"chapters_planned\"),\n",
    "            \"total_words\": stats.get(\"total_words\") or qa_metrics.get(\"word_count\"),\n",
    "            \"unresolved_placeholders_total\": stats.get(\"unresolved_placeholders_total\") or qa_metrics.get(\"unresolved_total\"),\n",
    "            \"unresolved_per_10k_words\": stats.get(\"unresolved_per_10k_words\") or qa_metrics.get(\"unresolved_per_10k_words\"),\n",
    "        },\n",
    "        \"qa\": {\n",
    "            \"outcome\": qa.get(\"outcome\"),\n",
    "            \"warnings_count\": len(qa.get(\"warnings\", [])),\n",
    "            \"warnings\": qa.get(\"warnings\", [])[:25],  # keep top warnings short\n",
    "            \"checks\": {\n",
    "                **qa_checks,\n",
    "                # expose key KPIs explicitly\n",
    "                \"kpi_unresolved_ok\": qa_checks.get(\"unresolved_kpi_ok\"),\n",
    "                \"kpi_value_shift_ok\": qa_checks.get(\"kpi_value_shift_ok\"),\n",
    "                \"kpi_hook_ok\": qa_checks.get(\"kpi_hook_ok\"),\n",
    "                \"kpi_claim_coverage_ok\": qa_checks.get(\"kpi_claim_coverage_ok\"),\n",
    "            },\n",
    "            \"gate_kpis\": {\n",
    "                \"rollup\": gate_roll,\n",
    "                \"per_chapter\": gate_per_ch,\n",
    "            },\n",
    "        },\n",
    "        \"exports\": {\n",
    "            \"docx\": export_log.get(\"docx\"),\n",
    "            \"epub\": export_log.get(\"epub\"),\n",
    "            \"pdf\": export_log.get(\"pdf\"),\n",
    "            \"log\": export_log.get(\"log\"),\n",
    "        },\n",
    "        \"run_manifest\": run_manifest or None,\n",
    "        \"logs_tail\": {\n",
    "            \"agent_calls_jsonl_tail\": _tail_text(\"logs/agent_calls.jsonl\", 40),\n",
    "            \"last_error_tail\": _tail_text(\"logs/last_error.txt\", 60),\n",
    "        },\n",
    "    }\n",
    "\n",
    "    write_json(\"dist/manifest.json\", m)\n",
    "    print(\"[manifest] dist/manifest.json written\")\n",
    "    return m\n",
    "\n",
    "print(\"Manifest ready\")"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": 16,
   "metadata": {},
   "outputs": [
    {
     "name": "stdout",
     "output_type": "stream",
     "text": [
      "Controls ready\n"
     ]
    }
   ],
   "source": [
    "# Cell 12 — Run-All / Rerun (aligned with assembly-only Cell 8 and separate export Cell 10)\n",
    "\n",
    "from pathlib import Path\n",
    "import os\n",
    "import json\n",
    "\n",
    "def make_router():\n",
    "    tr = CostTracker(pipeline_config[\"RUN_COST_CAP_USD\"])\n",
    "    rt = PMRouter(tr)\n",
    "    return rt, tr\n",
    "\n",
    "def run_all():\n",
    "    Path(\"logs\").mkdir(parents=True, exist_ok=True)\n",
    "    if os.getenv(\"DRY_RUN\") == \"1\":\n",
    "        print(\"[run_all] DRY_RUN=1 → skipping API calls; using synthetic outputs.\")\n",
    "\n",
    "    out = \"PASS\"\n",
    "    tr = None  # ensure defined for finally\n",
    "\n",
    "    try:\n",
    "        rt, tr = make_router()\n",
    "\n",
    "        # Style & outline\n",
    "        try:\n",
    "            gen_style(book_spec)\n",
    "        except Exception as e:\n",
    "            log_exc(\"gen_style\", e)\n",
    "\n",
    "        pj, pm = gen_outline(book_spec, tr, rt)\n",
    "\n",
    "        # Chapter selection based on config\n",
    "        total = int(book_spec.get(\"chapters\", 0) or 0)\n",
    "        if pipeline_config.get(\"FULL_RUN\", True):\n",
    "            chs = list(range(1, total + 1))\n",
    "        else:\n",
    "            sample_n = int(pipeline_config.get(\"SAMPLE_RUN_CHAPTERS\", 2))\n",
    "            chs = list(range(1, min(total, sample_n) + 1))\n",
    "\n",
    "        # Per-chapter processing with cost-cap handling\n",
    "        for ch in chs:\n",
    "            try:\n",
    "                process_chapter(ch, pipeline_config, tr, rt, pj)\n",
    "            except CostCapExceededException as e:\n",
    "                write_text(\"logs/last_error.txt\", str(e))\n",
    "                out = \"ABORTED_COST_CAP\"\n",
    "                break\n",
    "            except Exception as e:\n",
    "                log_exc(f\"process_chapter_{ch}\", e)\n",
    "                out = \"FAILED_STEP\"\n",
    "                break\n",
    "\n",
    "        # Assembly (no exports here)\n",
    "        try:\n",
    "            assemble_book(book_spec)\n",
    "        except Exception as e:\n",
    "            log_exc(\"assemble_book\", e)\n",
    "            return \"FAILED_STEP\"\n",
    "\n",
    "        # QA\n",
    "        try:\n",
    "            qa = run_qa(book_spec)\n",
    "            if qa.get(\"warnings\"):\n",
    "                out = \"PASS_WITH_WARNINGS\" if out == \"PASS\" else out\n",
    "        except Exception as e:\n",
    "            log_exc(\"run_qa\", e)\n",
    "            return \"FAILED_STEP\"\n",
    "\n",
    "        # Export (separate cell handles pandoc/fallbacks)\n",
    "        try:\n",
    "            export_deliverables()\n",
    "        except Exception as e:\n",
    "            log_exc(\"export_deliverables\", e)\n",
    "            return \"FAILED_STEP\"\n",
    "\n",
    "        return out\n",
    "\n",
    "    except CostCapExceededException as e:\n",
    "        write_text(\"logs/last_error.txt\", str(e))\n",
    "        out = \"ABORTED_COST_CAP\"\n",
    "    except Exception as e:\n",
    "        write_text(\"logs/last_error.txt\", \"FAILED_STEP: \" + str(e))\n",
    "        out = \"FAILED_STEP\"\n",
    "    finally:\n",
    "        try:\n",
    "            manifest(tr, out) if tr else None\n",
    "        except Exception as e:\n",
    "            # last-ditch logging if manifest itself fails\n",
    "            write_text(\"logs/last_error.txt\", \"MANIFEST_FAILED: \" + str(e))\n",
    "\n",
    "    print(\"Run done\", out)\n",
    "    return out\n",
    "\n",
    "def rerun_chapter(n):\n",
    "    n = int(n)\n",
    "    # Clear prior artifacts for this chapter\n",
    "    for sub in [\"research\", \"drafts\", \"edits\"]:\n",
    "        d = Path(f\"content/{sub}/{n:02d}\")\n",
    "        if d.exists():\n",
    "            for p in d.glob(\"*\"):\n",
    "                if p.is_file():\n",
    "                    p.unlink()\n",
    "\n",
    "    Path(\"logs\").mkdir(parents=True, exist_ok=True)\n",
    "\n",
    "    rt, tr = make_router()\n",
    "    pj = \"content/outline/outline.json\"\n",
    "    if not Path(pj).exists():\n",
    "        pj, _ = gen_outline(book_spec, tr, rt)\n",
    "\n",
    "    process_chapter(n, pipeline_config, tr, rt, pj)\n",
    "    assemble_book(book_spec)\n",
    "    run_qa(book_spec)\n",
    "    export_deliverables()\n",
    "    manifest(tr, \"PASS\")\n",
    "    print(\"Rerun done\", n)\n",
    "\n",
    "def resume():\n",
    "    return run_all()\n",
    "\n",
    "def verify_author_calls():\n",
    "    from pathlib import Path\n",
    "    import json\n",
    "\n",
    "    print(\"AuthorAgent call verification:\\n\")\n",
    "    any_found = False\n",
    "    # Chapter stamps\n",
    "    for p in sorted(Path(\"content/drafts\").rglob(\"*/.author_call.json\")):\n",
    "        any_found = True\n",
    "        d = json.loads(p.read_text(encoding=\"utf-8\"))\n",
    "        ch = p.parent.name\n",
    "        print(\n",
    "            f'- drafts/{ch}: called @ {d.get(\"t\")} | cached={d.get(\"cached\")} | title=\"{d.get(\"outline_title\", \"\")}\"'\n",
    "        )\n",
    "    if not any_found:\n",
    "        print(\"- No .author_call.json stamps found in content/drafts/*\")\n",
    "    print(\"\\nRecent agent call events (tail 8):\")\n",
    "    log = Path(\"logs/agent_calls.jsonl\")\n",
    "    if log.exists():\n",
    "        lines = log.read_text(encoding=\"utf-8\").splitlines()[-8:]\n",
    "        for ln in lines:\n",
    "            print(\"  \", ln)\n",
    "    else:\n",
    "        print(\"  logs/agent_calls.jsonl (missing)\")\n",
    "\n",
    "print(\"Controls ready\")"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": 17,
   "metadata": {},
   "outputs": [
    {
     "name": "stdout",
     "output_type": "stream",
     "text": [
      "[style] guide: content\\style\\style_guide.md\n",
      "[style] glossary: content\\style\\glossary.json | voice_cards: content\\style\\voice_cards.json\n",
      "[style] packs: content\\style\\packs.json\n",
      "Chapter processor ready (ch01)\n",
      "[assembly] book.md, toc.json, stats.json written (no exports).\n",
      "[qa] Report written to dist/qa_report.json\n",
      "[export] Export results written: {'docx': 'dist/book.docx', 'epub': 'dist/book.epub'}\n",
      "[manifest] dist/manifest.json written\n",
      "Converting DOCX to PDF via docx2pdf...\n"
     ]
    },
    {
     "name": "stderr",
     "output_type": "stream",
     "text": [
      "c:\\Users\\calig\\AppData\\Local\\Programs\\Python\\Python313\\Lib\\site-packages\\tqdm\\auto.py:21: TqdmWarning: IProgress not found. Please update jupyter and ipywidgets. See https://ipywidgets.readthedocs.io/en/stable/user_install.html\n",
      "  from .autonotebook import tqdm as notebook_tqdm\n",
      "100%|██████████| 1/1 [00:05<00:00,  5.36s/it]"
     ]
    },
    {
     "name": "stdout",
     "output_type": "stream",
     "text": [
      "PDF saved at: dist\\book.pdf\n",
      "Outcome: PASS_WITH_WARNINGS\n",
      "Cost: {'total_spent_usd': 0.156982, 'run_cap_usd': 3.0, 'remaining_usd': 2.843018, 'events': 20, 'ts': '2025-09-12T22:12:32.601962Z'}\n",
      "\n",
      "Build:\n",
      "build/_meta.yml (154 bytes)\n",
      "build/book.md (27777 bytes)\n",
      "build/export_log.json (1283 bytes)\n",
      "build/stats.json (221 bytes)\n",
      "build/toc.json (2216 bytes)\n",
      "\n",
      "Dist:\n",
      "dist/book.docx (21746 bytes)\n",
      "dist/book.epub (16260 bytes)\n",
      "dist/book.md (27777 bytes)\n",
      "dist/book.pdf (109597 bytes)\n",
      "dist/manifest.json (12900 bytes)\n",
      "dist/qa_report.json (4129 bytes)\n"
     ]
    },
    {
     "name": "stderr",
     "output_type": "stream",
     "text": [
      "\n"
     ]
    }
   ],
   "source": [
    "# Cell 13 — Demonstration Mode (sample run; research off for speed; robust crash handling)\n",
    "\n",
    "from pathlib import Path\n",
    "import os, sys, shutil, subprocess, traceback\n",
    "import json\n",
    "from datetime import datetime, timezone\n",
    "\n",
    "# Cheap demo run: sample a couple of chapters and DISABLE research for speed\n",
    "pipeline_config[\"SAMPLE_RUN_CHAPTERS\"] = int(pipeline_config.get(\"SAMPLE_RUN_CHAPTERS\", 2))\n",
    "pipeline_config[\"FULL_RUN\"] = False\n",
    "pipeline_config[\"RESEARCH_ENABLED\"] = True  # demo: faster, no web/research agent calls\n",
    "\n",
    "try:\n",
    "    outcome = run_all()\n",
    "except Exception as e:\n",
    "    Path(\"dist\").mkdir(parents=True, exist_ok=True)\n",
    "    crash_manifest = {\n",
    "        \"outcome\": \"FAILED_STEP\",\n",
    "        \"failed_step\": \"run_all_top_level\",\n",
    "        \"error\": str(e),\n",
    "        \"trace\": traceback.format_exc(),\n",
    "        \"t\": datetime.now(timezone.utc).isoformat().replace(\"+00:00\", \"Z\"),\n",
    "    }\n",
    "    Path(\"dist/manifest.json\").write_text(\n",
    "        json.dumps(crash_manifest, indent=2), encoding=\"utf-8\"\n",
    "    )\n",
    "    print(\"run_all crashed. Details saved to dist/manifest.json\")\n",
    "    outcome = \"FAILED_STEP\"\n",
    "\n",
    "docx_path = Path(\"dist/book.docx\")\n",
    "pdf_path = Path(\"dist/book.pdf\")\n",
    "pdf_path.parent.mkdir(parents=True, exist_ok=True)\n",
    "\n",
    "def _have_exe(cmd: str) -> bool:\n",
    "    return shutil.which(cmd) is not None\n",
    "\n",
    "def convert_docx_to_pdf(src: Path, dst: Path) -> bool:\n",
    "    \"\"\"\n",
    "    Best-effort DOCX→PDF:\n",
    "      1) docx2pdf (Windows/macOS w/ MS Word)\n",
    "      2) LibreOffice soffice --headless (cross-platform)\n",
    "      3) pandoc (if installed)\n",
    "    Returns True on success, False otherwise.\n",
    "    \"\"\"\n",
    "    # 1) docx2pdf on win/darwin only\n",
    "    try:\n",
    "        if sys.platform in (\"win32\", \"darwin\"):\n",
    "            from docx2pdf import convert  # may raise ImportError\n",
    "            print(\"Converting DOCX to PDF via docx2pdf...\")\n",
    "            convert(str(src), str(dst))\n",
    "            return dst.exists() and dst.stat().st_size > 0\n",
    "        else:\n",
    "            print(\"docx2pdf is typically not supported on this platform; skipping that route.\")\n",
    "    except Exception as e:\n",
    "        print(f\"docx2pdf failed: {e}\")\n",
    "\n",
    "    # 2) LibreOffice (soffice)\n",
    "    if _have_exe(\"soffice\"):\n",
    "        try:\n",
    "            print(\"Converting via LibreOffice (soffice --headless)...\")\n",
    "            outdir = dst.parent\n",
    "            cmd = [\n",
    "                \"soffice\",\n",
    "                \"--headless\",\n",
    "                \"--convert-to\",\n",
    "                \"pdf\",\n",
    "                \"--outdir\",\n",
    "                str(outdir),\n",
    "                str(src),\n",
    "            ]\n",
    "            subprocess.run(cmd, check=True, stdout=subprocess.PIPE, stderr=subprocess.PIPE)\n",
    "            produced = outdir / (src.stem + \".pdf\")\n",
    "            if produced.exists():\n",
    "                if produced != dst:\n",
    "                    produced.replace(dst)\n",
    "                return dst.exists() and dst.stat().st_size > 0\n",
    "        except Exception as e:\n",
    "            print(f\"LibreOffice conversion failed: {e}\")\n",
    "\n",
    "    # 3) Pandoc\n",
    "    if _have_exe(\"pandoc\"):\n",
    "        try:\n",
    "            print(\"Converting via pandoc...\")\n",
    "            subprocess.run([\"pandoc\", str(src), \"-o\", str(dst)], check=True)\n",
    "            return dst.exists() and dst.stat().st_size > 0\n",
    "        except Exception as e:\n",
    "            print(f\"pandoc conversion failed: {e}\")\n",
    "\n",
    "    print(\"No PDF converter available (docx2pdf/soffice/pandoc not working). Skipping PDF export.\")\n",
    "    return False\n",
    "\n",
    "if docx_path.exists():\n",
    "    success = convert_docx_to_pdf(docx_path, pdf_path)\n",
    "    if success:\n",
    "        print(f\"PDF saved at: {pdf_path}\")\n",
    "    else:\n",
    "        print(\"PDF export skipped or failed. See messages above.\")\n",
    "else:\n",
    "    print(\"DOCX file not found. Cannot export PDF.\")\n",
    "\n",
    "m = read_json(\"dist/manifest.json\") or {}\n",
    "print(\"Outcome:\", m.get(\"outcome\", outcome))\n",
    "print(\"Cost:\", m.get(\"cost_summary\"))\n",
    "\n",
    "def tree(d):\n",
    "    d = Path(d)\n",
    "    if not d.exists():\n",
    "        print(f\"{d.as_posix()} (missing)\")\n",
    "        return\n",
    "    for p in sorted(d.rglob(\"*\")):\n",
    "        if p.is_file():\n",
    "            rel = p.relative_to(d).as_posix()\n",
    "            print(f\"{d.as_posix()}/{rel} ({p.stat().st_size} bytes)\")\n",
    "\n",
    "print(\"\\nBuild:\")\n",
    "tree(\"build\")\n",
    "\n",
    "print(\"\\nDist:\")\n",
    "tree(\"dist\")"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": 18,
   "id": "1d866d8c",
   "metadata": {},
   "outputs": [
    {
     "name": "stdout",
     "output_type": "stream",
     "text": [
      "=== TRIAGE @ 2025-09-12T22:12:41.964377Z ===\n",
      "\n",
      "Outcome: PASS_WITH_WARNINGS\n",
      "Failed step: None\n",
      "Notes: None\n",
      "Artifacts (legacy key): None\n",
      "\n",
      "Cost summary:\n",
      "  total_spent_usd: 0.156982\n",
      "  run_cap_usd    : 3.0\n",
      "  remaining_usd  : 2.843018\n",
      "  log_items      : None\n",
      "\n",
      "Build stats:\n",
      "  title                    : Ghost Protocol: The Oracle Gambit\n",
      "  chapters_planned         : 18\n",
      "  total_words              : 4732\n",
      "  unresolved_placeholders  : 17\n",
      "  unresolved_per_10k_words : 35.93\n",
      "\n",
      "QA:\n",
      "  outcome         : PASS_WITH_WARNINGS\n",
      "  warnings_count  : 6\n",
      "  warnings (first 5):\n",
      "   - Word count 4752 vs target 95000 (acceptable range 85500-104500).\n",
      "   - Chapters marked missing in TOC: [2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18]\n",
      "   - Unresolved placeholders 35.77/10k words (limit ≤ 2).\n",
      "   - Value-Shift KPI avg 90.00% < target 95%.\n",
      "   - Hook/Cliffhanger KPI avg 85.00% < target 90%.\n",
      "\n",
      "Exports:\n",
      "  docx: dist/book.docx\n",
      "  epub: dist/book.epub\n",
      "  pdf : None\n",
      "  log (first 3):\n",
      "   - PDF export failed: no LaTeX engine detected. Install tectonic/xelatex.\n",
      "\n",
      "Recent JSON logs: ['ch01_gates.json', 'cost.json', 'run_manifest.json']\n",
      "\n",
      "agent_calls.jsonl (tail ~4k chars):\n",
      "\n",
      "uthor_block\",\"t\":\"2025-09-12T22:09:13.966525Z\",\"ts\":\"2025-09-12T22:09:13.966541Z\"}\n",
      "{\"agent\":\"Author\",\"duration_s\":21.48,\"event\":\"llm_ok\",\"label\":\"ch1_draft_s2\",\"model\":\"gpt-4o\",\"stage\":\"author_block\",\"t\":\"2025-09-12T22:09:35.448803Z\",\"ts\":\"2025-09-12T22:09:35.448824Z\",\"usage\":{\"completion_tokens\":1228,\"prompt_tokens\":2904,\"via\":\"chat.completions\"}}\n",
      "{\"agent\":\"Author\",\"cache_key\":\"c919f9839307ea397c364104a6ba2bdfff1d01c3\",\"event\":\"begin\",\"force_json\":false,\"label\":\"ch1_draft_s3\",\"max_t\":10000,\"model\":\"gpt-4o\",\"stage\":\"author_block\",\"t\":\"2025-09-12T22:09:35.454292Z\",\"ts\":\"2025-09-12T22:09:35.454306Z\"}\n",
      "{\"agent\":\"Author\",\"duration_s\":20.656,\"event\":\"llm_ok\",\"label\":\"ch1_draft_s3\",\"model\":\"gpt-4o\",\"stage\":\"author_block\",\"t\":\"2025-09-12T22:09:56.112140Z\",\"ts\":\"2025-09-12T22:09:56.112164Z\",\"usage\":{\"completion_tokens\":1034,\"prompt_tokens\":3191,\"via\":\"chat.completions\"}}\n",
      "{\"agent\":\"Author\",\"cache_key\":\"696c95261ecaa779300dcc49ee78e1c5d2211038\",\"event\":\"begin\",\"force_json\":false,\"label\":\"ch1_draft_s4\",\"max_t\":10000,\"model\":\"gpt-4o\",\"stage\":\"author_block\",\"t\":\"2025-09-12T22:09:56.118781Z\",\"ts\":\"2025-09-12T22:09:56.118793Z\"}\n",
      "{\"agent\":\"Author\",\"duration_s\":27.043,\"event\":\"llm_ok\",\"label\":\"ch1_draft_s4\",\"model\":\"gpt-4o\",\"stage\":\"author_block\",\"t\":\"2025-09-12T22:10:23.163903Z\",\"ts\":\"2025-09-12T22:10:23.163914Z\",\"usage\":{\"completion_tokens\":1194,\"prompt_tokens\":3147,\"via\":\"chat.completions\"}}\n",
      "{\"agent\":\"Author\",\"cache_key\":\"c70def9fd079b73d7657336307c159aa2e976fb5\",\"event\":\"begin\",\"force_json\":false,\"label\":\"ch1_draft_s5\",\"max_t\":10000,\"model\":\"gpt-4o\",\"stage\":\"author_block\",\"t\":\"2025-09-12T22:10:23.170221Z\",\"ts\":\"2025-09-12T22:10:23.170234Z\"}\n",
      "{\"agent\":\"Author\",\"duration_s\":28.041,\"event\":\"llm_ok\",\"label\":\"ch1_draft_s5\",\"model\":\"gpt-4o\",\"stage\":\"author_block\",\"t\":\"2025-09-12T22:10:51.213190Z\",\"ts\":\"2025-09-12T22:10:51.213221Z\",\"usage\":{\"completion_tokens\":1350,\"prompt_tokens\":3182,\"via\":\"chat.completions\"}}\n",
      "{\"agent\":\"Editor\",\"cache_key\":\"bcaf22df70d20841889fa97bad92ea879d44934a\",\"event\":\"begin\",\"force_json\":false,\"label\":\"ch1_edit\",\"max_t\":16384,\"model\":\"gpt-4o\",\"stage\":\"editor\",\"t\":\"2025-09-12T22:10:51.224246Z\",\"ts\":\"2025-09-12T22:10:51.224258Z\"}\n",
      "{\"agent\":\"Editor\",\"duration_s\":95.048,\"event\":\"llm_ok\",\"label\":\"ch1_edit\",\"model\":\"gpt-4o\",\"stage\":\"editor\",\"t\":\"2025-09-12T22:12:26.274271Z\",\"ts\":\"2025-09-12T22:12:26.274303Z\",\"usage\":{\"completion_tokens\":6060,\"prompt_tokens\":6773,\"via\":\"chat.completions\"}}\n",
      "{\"agent\":\"Research\",\"cache_key\":\"4804d18d8645ec98acb172319b4d743f9ce3c5c3\",\"event\":\"begin\",\"force_json\":true,\"label\":\"ch1_research_claims\",\"max_t\":5500,\"model\":\"gpt-4o\",\"stage\":\"research\",\"t\":\"2025-09-12T22:12:26.288747Z\",\"ts\":\"2025-09-12T22:12:26.288761Z\"}\n",
      "{\"agent\":\"Research\",\"duration_s\":2.785,\"event\":\"llm_ok\",\"label\":\"ch1_research_claims\",\"model\":\"gpt-4o\",\"stage\":\"research\",\"t\":\"2025-09-12T22:12:29.075552Z\",\"ts\":\"2025-09-12T22:12:29.075563Z\",\"usage\":{\"completion_tokens\":148,\"prompt_tokens\":271,\"via\":\"chat.completions\"}}\n",
      "{\"agent\":\"Editor\",\"cache_key\":\"3036a8bb0192bbc93674c4d49a3b2b62c8a64c16\",\"event\":\"begin\",\"force_json\":true,\"label\":\"ch1_gate_value_shift\",\"max_t\":300,\"model\":\"gpt-4o\",\"stage\":\"gate_check\",\"t\":\"2025-09-12T22:12:29.079686Z\",\"ts\":\"2025-09-12T22:12:29.079693Z\"}\n",
      "{\"agent\":\"Editor\",\"duration_s\":1.089,\"event\":\"llm_ok\",\"label\":\"ch1_gate_value_shift\",\"model\":\"gpt-4o\",\"stage\":\"gate_check\",\"t\":\"2025-09-12T22:12:30.170021Z\",\"ts\":\"2025-09-12T22:12:30.170049Z\",\"usage\":{\"completion_tokens\":11,\"prompt_tokens\":5834,\"via\":\"chat.completions\"}}\n",
      "{\"agent\":\"Editor\",\"cache_key\":\"a9c82d0be4023bfb1ca14f215c9817b1e87823ba\",\"event\":\"begin\",\"force_json\":true,\"label\":\"ch1_gate_hook\",\"max_t\":250,\"model\":\"gpt-4o\",\"stage\":\"gate_check\",\"t\":\"2025-09-12T22:12:30.174107Z\",\"ts\":\"2025-09-12T22:12:30.174116Z\"}\n",
      "{\"agent\":\"Editor\",\"duration_s\":0.731,\"event\":\"llm_ok\",\"label\":\"ch1_gate_hook\",\"model\":\"gpt-4o\",\"stage\":\"gate_check\",\"t\":\"2025-09-12T22:12:30.906423Z\",\"ts\":\"2025-09-12T22:12:30.906437Z\",\"usage\":{\"completion_tokens\":9,\"prompt_tokens\":631,\"via\":\"chat.completions\"}}\n",
      "\n",
      "\n",
      "logs/run_manifest.json (keys): ['title', 'kind', 'pack', 'outline_schema', 'chapters_planned', 'outline_paths', 'outline_generated_at', 'updated_at']\n",
      "\n",
      "Artifacts presence:\n",
      "  build/book.md : True | size: 27777\n",
      "  build/toc.json: True\n",
      "  dist/book.docx: True | size: 21746\n",
      "  dist/book.epub: True | size: 16260\n",
      "  dist/book.pdf : True | size: 109597\n",
      "\n",
      "Binaries available:\n",
      "  pandoc   : True | pandoc 3.8\n",
      "  pdf eng. : (none detected)\n",
      "  soffice : True\n",
      "\n",
      "Build tree:\n",
      "build/_meta.yml (154 bytes)\n",
      "build/book.md (27777 bytes)\n",
      "build/export_log.json (1283 bytes)\n",
      "build/stats.json (221 bytes)\n",
      "build/toc.json (2216 bytes)\n",
      "\n",
      "Dist tree:\n",
      "dist/book.docx (21746 bytes)\n",
      "dist/book.epub (16260 bytes)\n",
      "dist/book.md (27777 bytes)\n",
      "dist/book.pdf (109597 bytes)\n",
      "dist/manifest.json (12900 bytes)\n",
      "dist/qa_report.json (4129 bytes)\n",
      "\n",
      "Directory sizes (bytes):\n",
      "  cache             : 70622\n",
      "  content/drafts    : 27448\n",
      "  content/edits     : 27984\n",
      "  content/research  : 684\n",
      "\n",
      "Next actions:\n",
      "- Build passed with warnings. See QA warnings above; address end-matter gaps, heading issues, or unresolved [n] markers.\n",
      "- Unresolved placeholders exceed KPI (35.93/10k). Run selective research or patch drafts to resolve [n]/TODO/TK.\n",
      "- Missing edited chapters: [2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18]. Re-run process_chapter() for those numbers.\n",
      "- Top QA fixes:\n",
      "    • Word count 4752 vs target 95000 (acceptable range 85500-104500).\n",
      "    • Chapters marked missing in TOC: [2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18]\n",
      "\n",
      "[triage] Snapshot written to dist/triage_snapshot.json\n",
      "AuthorAgent call verification:\n",
      "\n",
      "- drafts/01: called @ 2025-09-12T22:10:51.221374Z | cached=False | title=\"Trigger Protocol\"\n",
      "\n",
      "Recent agent call events (tail 8):\n",
      "   {\"agent\":\"Editor\",\"cache_key\":\"bcaf22df70d20841889fa97bad92ea879d44934a\",\"event\":\"begin\",\"force_json\":false,\"label\":\"ch1_edit\",\"max_t\":16384,\"model\":\"gpt-4o\",\"stage\":\"editor\",\"t\":\"2025-09-12T22:10:51.224246Z\",\"ts\":\"2025-09-12T22:10:51.224258Z\"}\n",
      "   {\"agent\":\"Editor\",\"duration_s\":95.048,\"event\":\"llm_ok\",\"label\":\"ch1_edit\",\"model\":\"gpt-4o\",\"stage\":\"editor\",\"t\":\"2025-09-12T22:12:26.274271Z\",\"ts\":\"2025-09-12T22:12:26.274303Z\",\"usage\":{\"completion_tokens\":6060,\"prompt_tokens\":6773,\"via\":\"chat.completions\"}}\n",
      "   {\"agent\":\"Research\",\"cache_key\":\"4804d18d8645ec98acb172319b4d743f9ce3c5c3\",\"event\":\"begin\",\"force_json\":true,\"label\":\"ch1_research_claims\",\"max_t\":5500,\"model\":\"gpt-4o\",\"stage\":\"research\",\"t\":\"2025-09-12T22:12:26.288747Z\",\"ts\":\"2025-09-12T22:12:26.288761Z\"}\n",
      "   {\"agent\":\"Research\",\"duration_s\":2.785,\"event\":\"llm_ok\",\"label\":\"ch1_research_claims\",\"model\":\"gpt-4o\",\"stage\":\"research\",\"t\":\"2025-09-12T22:12:29.075552Z\",\"ts\":\"2025-09-12T22:12:29.075563Z\",\"usage\":{\"completion_tokens\":148,\"prompt_tokens\":271,\"via\":\"chat.completions\"}}\n",
      "   {\"agent\":\"Editor\",\"cache_key\":\"3036a8bb0192bbc93674c4d49a3b2b62c8a64c16\",\"event\":\"begin\",\"force_json\":true,\"label\":\"ch1_gate_value_shift\",\"max_t\":300,\"model\":\"gpt-4o\",\"stage\":\"gate_check\",\"t\":\"2025-09-12T22:12:29.079686Z\",\"ts\":\"2025-09-12T22:12:29.079693Z\"}\n",
      "   {\"agent\":\"Editor\",\"duration_s\":1.089,\"event\":\"llm_ok\",\"label\":\"ch1_gate_value_shift\",\"model\":\"gpt-4o\",\"stage\":\"gate_check\",\"t\":\"2025-09-12T22:12:30.170021Z\",\"ts\":\"2025-09-12T22:12:30.170049Z\",\"usage\":{\"completion_tokens\":11,\"prompt_tokens\":5834,\"via\":\"chat.completions\"}}\n",
      "   {\"agent\":\"Editor\",\"cache_key\":\"a9c82d0be4023bfb1ca14f215c9817b1e87823ba\",\"event\":\"begin\",\"force_json\":true,\"label\":\"ch1_gate_hook\",\"max_t\":250,\"model\":\"gpt-4o\",\"stage\":\"gate_check\",\"t\":\"2025-09-12T22:12:30.174107Z\",\"ts\":\"2025-09-12T22:12:30.174116Z\"}\n",
      "   {\"agent\":\"Editor\",\"duration_s\":0.731,\"event\":\"llm_ok\",\"label\":\"ch1_gate_hook\",\"model\":\"gpt-4o\",\"stage\":\"gate_check\",\"t\":\"2025-09-12T22:12:30.906423Z\",\"ts\":\"2025-09-12T22:12:30.906437Z\",\"usage\":{\"completion_tokens\":9,\"prompt_tokens\":631,\"via\":\"chat.completions\"}}\n",
      "Triage ready\n"
     ]
    }
   ],
   "source": [
    "# TRIAGE CELL — diagnose failing step and what's been produced (robust, actionable, with snapshot)\n",
    "\n",
    "from __future__ import annotations\n",
    "from pathlib import Path\n",
    "from datetime import datetime, timezone\n",
    "import json, os, re, shutil, subprocess\n",
    "\n",
    "# ---------------------------- small local helpers ----------------------------\n",
    "\n",
    "def _safe_json(path: str):\n",
    "    p = Path(path)\n",
    "    if not p.exists():\n",
    "        return {}\n",
    "    try:\n",
    "        return json.loads(p.read_text(encoding=\"utf-8\"))\n",
    "    except Exception as e:\n",
    "        print(f\"[triage] Couldn't parse {path}: {e}\")\n",
    "        return {}\n",
    "\n",
    "def _safe_text(path: str, limit: int | None = None) -> str:\n",
    "    p = Path(path)\n",
    "    if not p.exists():\n",
    "        return \"\"\n",
    "    s = p.read_text(encoding=\"utf-8\", errors=\"replace\")\n",
    "    return s if limit is None else (s[:limit] + (\"...\" if len(s) > limit else \"\"))\n",
    "\n",
    "def _tree(d: str):\n",
    "    dpath = Path(d)\n",
    "    if not dpath.exists():\n",
    "        print(f\"{dpath.as_posix()} (missing)\")\n",
    "        return\n",
    "    for p in sorted(dpath.rglob(\"*\")):\n",
    "        if p.is_file():\n",
    "            rel = p.relative_to(dpath).as_posix()\n",
    "            print(f\"{dpath.as_posix()}/{rel} ({p.stat().st_size} bytes)\")\n",
    "\n",
    "def _exists_size(p: str) -> tuple[bool, int]:\n",
    "    q = Path(p)\n",
    "    return (q.exists(), (q.stat().st_size if q.exists() else 0))\n",
    "\n",
    "def _sum_dir_bytes(d: str) -> int:\n",
    "    root = Path(d)\n",
    "    if not root.exists():\n",
    "        return 0\n",
    "    total = 0\n",
    "    for p in root.rglob(\"*\"):\n",
    "        if p.is_file():\n",
    "            total += p.stat().st_size\n",
    "    return total\n",
    "\n",
    "def _which(cmd: str) -> str | None:\n",
    "    return shutil.which(cmd)\n",
    "\n",
    "def _pandoc_version() -> str | None:\n",
    "    exe = _which(\"pandoc\")\n",
    "    if not exe:\n",
    "        return None\n",
    "    try:\n",
    "        out = subprocess.run([exe, \"--version\"], stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True)\n",
    "        return (out.stdout.splitlines() or [\"\"])[0].strip()\n",
    "    except Exception:\n",
    "        return None\n",
    "\n",
    "def _pdf_engine_detect() -> str | None:\n",
    "    for cand in (\"tectonic\", \"xelatex\", \"pdflatex\", \"wkhtmltopdf\"):\n",
    "        if _which(cand):\n",
    "            return cand\n",
    "    return None\n",
    "\n",
    "def _now_iso():\n",
    "    return datetime.now(timezone.utc).isoformat().replace(\"+00:00\", \"Z\")\n",
    "\n",
    "def _outline_chapter_count() -> int:\n",
    "    d = _safe_json(\"content/outline/outline.json\")\n",
    "    if isinstance(d.get(\"chapters\"), list):\n",
    "        return len(d[\"chapters\"])\n",
    "    s = _safe_json(\"build/stats.json\")\n",
    "    return int(s.get(\"chapters_planned\") or 0)\n",
    "\n",
    "def _missing_chapters(n: int) -> list[int]:\n",
    "    missing = []\n",
    "    for ch in range(1, max(1, n) + 1):\n",
    "        ep = Path(f\"content/edits/{ch:02d}/chapter_{ch:02d}.md\")\n",
    "        if not ep.exists():\n",
    "            missing.append(ch)\n",
    "    return missing\n",
    "\n",
    "# ---------------------------- 1) Manifest snapshot ---------------------------\n",
    "\n",
    "man = _safe_json(\"dist/manifest.json\")\n",
    "print(\"=== TRIAGE @\", _now_iso(), \"===\\n\")\n",
    "\n",
    "print(\"Outcome:\", man.get(\"outcome\"))\n",
    "print(\"Failed step:\", man.get(\"failed_step\") or man.get(\"last_step\") or man.get(\"step\"))\n",
    "print(\"Notes:\", man.get(\"notes\"))\n",
    "print(\"Artifacts (legacy key):\", man.get(\"artifacts\"))\n",
    "\n",
    "# Cost summary (if present)\n",
    "cost = man.get(\"cost_summary\") or {}\n",
    "if cost:\n",
    "    print(\"\\nCost summary:\")\n",
    "    print(\"  total_spent_usd:\", cost.get(\"total_spent_usd\"))\n",
    "    print(\"  run_cap_usd    :\", cost.get(\"run_cap_usd\"))\n",
    "    print(\"  remaining_usd  :\", cost.get(\"remaining_usd\"))\n",
    "    print(\"  log_items      :\", cost.get(\"log_items\"))\n",
    "\n",
    "# Build stats\n",
    "stats = man.get(\"stats\") or _safe_json(\"build/stats.json\")\n",
    "if stats:\n",
    "    print(\"\\nBuild stats:\")\n",
    "    print(\"  title                    :\", stats.get(\"title\"))\n",
    "    print(\"  chapters_planned         :\", stats.get(\"chapters_planned\"))\n",
    "    print(\"  total_words              :\", stats.get(\"total_words\"))\n",
    "    print(\"  unresolved_placeholders  :\", stats.get(\"unresolved_placeholders_total\"))\n",
    "    print(\"  unresolved_per_10k_words :\", stats.get(\"unresolved_per_10k_words\"))\n",
    "\n",
    "# QA\n",
    "qa = man.get(\"qa\") or _safe_json(\"dist/qa_report.json\")\n",
    "if qa:\n",
    "    print(\"\\nQA:\")\n",
    "    print(\"  outcome         :\", qa.get(\"outcome\"))\n",
    "    wc = qa.get(\"warnings\")\n",
    "    print(\"  warnings_count  :\", len(wc) if isinstance(wc, list) else wc)\n",
    "    if isinstance(wc, list) and wc:\n",
    "        print(\"  warnings (first 5):\")\n",
    "        for w in wc[:5]:\n",
    "            print(\"   -\", w)\n",
    "\n",
    "    # Gate KPIs (rollups)\n",
    "    gm = (qa.get(\"gate_metrics\") or {})\n",
    "    roll = gm.get(\"rollup\") or {}\n",
    "    if roll:\n",
    "        print(\"  gate_rollup     :\", roll)\n",
    "\n",
    "# Exports (from export_log)\n",
    "exports = man.get(\"exports\") or _safe_json(\"build/export_log.json\")\n",
    "if exports:\n",
    "    print(\"\\nExports:\")\n",
    "    print(\"  docx:\", exports.get(\"docx\"))\n",
    "    print(\"  epub:\", exports.get(\"epub\"))\n",
    "    print(\"  pdf :\", exports.get(\"pdf\"))\n",
    "    if exports.get(\"pandoc_version\"):\n",
    "        print(\"  pandoc_version  :\", exports.get(\"pandoc_version\"))\n",
    "    if exports.get(\"log\"):\n",
    "        print(\"  log (first 3):\")\n",
    "        for line in (exports[\"log\"][:3] if isinstance(exports[\"log\"], list) else [exports[\"log\"]]):\n",
    "            print(\"   -\", line)\n",
    "\n",
    "# ---------------------------- 2) Recent logs & tails -------------------------\n",
    "\n",
    "# JSON logs (newest first)\n",
    "logs_json = sorted(Path(\"logs\").glob(\"*.json\"), key=lambda p: p.stat().st_mtime, reverse=True)\n",
    "print(\"\\nRecent JSON logs:\", [p.name for p in logs_json[:6]])\n",
    "\n",
    "# JSONL agent calls tail\n",
    "agent_calls_tail = _safe_text(\"logs/agent_calls.jsonl\", limit=4000)\n",
    "if agent_calls_tail:\n",
    "    print(\"\\nagent_calls.jsonl (tail ~4k chars):\\n\")\n",
    "    txt = Path(\"logs/agent_calls.jsonl\").read_text(encoding=\"utf-8\", errors=\"replace\")\n",
    "    print(txt[-4000:])\n",
    "\n",
    "# last_error.txt\n",
    "last_err = _safe_text(\"logs/last_error.txt\", limit=2000)\n",
    "if last_err:\n",
    "    print(\"\\nlast_error.txt (head 2k chars):\\n\")\n",
    "    print(last_err)\n",
    "\n",
    "# run manifest (if any)\n",
    "run_manifest = _safe_json(\"logs/run_manifest.json\")\n",
    "if run_manifest:\n",
    "    print(\"\\nlogs/run_manifest.json (keys):\", list(run_manifest.keys())[:10])\n",
    "\n",
    "# ---------------------------- 3) Artifact presence ---------------------------\n",
    "\n",
    "b_exists, b_size = _exists_size(\"build/book.md\")\n",
    "d_exists, d_size = _exists_size(\"dist/book.docx\")\n",
    "e_exists, e_size = _exists_size(\"dist/book.epub\")\n",
    "p_exists, p_size = _exists_size(\"dist/book.pdf\")\n",
    "toc_exists, _ = _exists_size(\"build/toc.json\")\n",
    "\n",
    "print(\"\\nArtifacts presence:\")\n",
    "print(\"  build/book.md :\", b_exists, \"| size:\", b_size)\n",
    "print(\"  build/toc.json:\", toc_exists)\n",
    "print(\"  dist/book.docx:\", d_exists, \"| size:\", d_size)\n",
    "print(\"  dist/book.epub:\", e_exists, \"| size:\", e_size)\n",
    "print(\"  dist/book.pdf :\", p_exists, \"| size:\", p_size)\n",
    "\n",
    "# Check pandoc availability (useful if exports failed)\n",
    "print(\"\\nBinaries available:\")\n",
    "print(f\"  pandoc   :\", bool(_which(\"pandoc\")), \"|\", (_pandoc_version() or \"(version n/a)\"))\n",
    "print(f\"  pdf eng. :\", _pdf_engine_detect() or \"(none detected)\")\n",
    "for exe in [\"soffice\"]:\n",
    "    print(f\"  {exe:8}:\", bool(_which(exe)))\n",
    "\n",
    "# ---------------------------- 4) Directory trees -----------------------------\n",
    "\n",
    "print(\"\\nBuild tree:\")\n",
    "_tree(\"build\")\n",
    "print(\"\\nDist tree:\")\n",
    "_tree(\"dist\")\n",
    "\n",
    "# Sizes of heavy dirs (cache transparency)\n",
    "print(\"\\nDirectory sizes (bytes):\")\n",
    "for d in [\"cache\", \"content/drafts\", \"content/edits\", \"content/research\"]:\n",
    "    print(f\"  {d:18}:\", _sum_dir_bytes(d))\n",
    "\n",
    "# ---------------------------- 5) Diagnostics & advice ------------------------\n",
    "\n",
    "def _diagnose():\n",
    "    outcome = (man.get(\"outcome\") or \"\").upper()\n",
    "    failed = (man.get(\"failed_step\") or man.get(\"last_step\") or man.get(\"step\") or \"\").upper()\n",
    "\n",
    "    advice = []\n",
    "\n",
    "    if outcome in (\"ABORTED_COST_CAP\",):\n",
    "        rem = (man.get(\"cost_summary\") or {}).get(\"remaining_usd\")\n",
    "        advice.append(f\"- Run aborted by cost cap. Remaining ${rem}. Raise RUN_COST_CAP_USD/CHAPTER_COST_CAP_USD or enable ULTRA_BUDGET_MODE, then rerun.\")\n",
    "    elif outcome in (\"FAILED_STEP\",):\n",
    "        advice.append(\"- A step failed. Check logs/last_error.txt above and the agent_calls.jsonl tail for the exact API error.\")\n",
    "    elif outcome in (\"PASS_WITH_WARNINGS\",):\n",
    "        advice.append(\"- Build passed with warnings. See QA warnings above; address end-matter gaps, heading issues, or unresolved [n] markers.\")\n",
    "    elif outcome in (\"PASS\",):\n",
    "        advice.append(\"- Build succeeded. If exports are missing, ensure pandoc (and LaTeX for PDF) is installed and rerun export_deliverables().\")\n",
    "\n",
    "    # Specific artifacts guidance\n",
    "    if b_exists and not d_exists:\n",
    "        advice.append(\"- book.md exists but DOCX is missing. Run export_deliverables(); if pandoc is missing, the pipeline should still create a minimal DOCX.\")\n",
    "    if not b_exists:\n",
    "        advice.append(\"- book.md missing: run assemble_book(spec) after resolving chapter generation.\")\n",
    "    if stats and isinstance(stats.get(\"unresolved_per_10k_words\"), (int, float)) and stats[\"unresolved_per_10k_words\"] > 2:\n",
    "        advice.append(f\"- Unresolved placeholders exceed KPI ({stats['unresolved_per_10k_words']}/10k). Run selective research or patch drafts to resolve [n]/TODO/TK.\")\n",
    "\n",
    "    # Gate KPI nudges\n",
    "    gm = (qa.get(\"gate_metrics\") or {}) if qa else {}\n",
    "    roll = gm.get(\"rollup\") or {}\n",
    "    if roll:\n",
    "        if \"value_shift_avg\" in roll and isinstance(roll[\"value_shift_avg\"], (int, float)) and roll[\"value_shift_avg\"] < 0.95:\n",
    "            advice.append(\"- Fiction KPI: Value-Shift average below 95%. Re-run tightening or tweak beats for weak scenes.\")\n",
    "        if \"hook_score_avg\" in roll and isinstance(roll[\"hook_score_avg\"], (int, float)) and roll[\"hook_score_avg\"] < 0.90:\n",
    "            advice.append(\"- Fiction KPI: Hook/Cliffhanger average below 90%. Strengthen final beats per chapter.\")\n",
    "        if \"claim_coverage_avg\" in roll and isinstance(roll[\"claim_coverage_avg\"], (int, float)) and roll[\"claim_coverage_avg\"] < 0.95:\n",
    "            advice.append(\"- Nonfiction KPI: Claim coverage below 95%. Add [n] markers where missing and run research to attach sources.\")\n",
    "\n",
    "    # Missing chapters quick check\n",
    "    n_plan = _outline_chapter_count()\n",
    "    missing = _missing_chapters(n_plan) if n_plan else []\n",
    "    if missing:\n",
    "        advice.append(f\"- Missing edited chapters: {missing}. Re-run process_chapter() for those numbers.\")\n",
    "\n",
    "    # Top QA warnings, again\n",
    "    if qa and isinstance(qa.get(\"warnings\"), list) and qa[\"warnings\"]:\n",
    "        advice.append(\"- Top QA fixes:\")\n",
    "        for w in qa[\"warnings\"][:2]:\n",
    "            advice.append(f\"    • {w}\")\n",
    "\n",
    "    # Show advice\n",
    "    if advice:\n",
    "        print(\"\\nNext actions:\")\n",
    "        for line in advice:\n",
    "            print(line)\n",
    "\n",
    "_diagnose()\n",
    "\n",
    "# ---------------------------- 6) Snapshot & optional verify ------------------\n",
    "\n",
    "def triage_snapshot() -> dict:\n",
    "    \"\"\"Write dist/triage_snapshot.json and return the snapshot dict.\"\"\"\n",
    "    outline = _safe_json(\"content/outline/outline.json\")\n",
    "    qa_rpt  = qa or _safe_json(\"dist/qa_report.json\")\n",
    "    build_s = stats or _safe_json(\"build/stats.json\")\n",
    "    manifest= man or _safe_json(\"dist/manifest.json\")\n",
    "    export  = exports or _safe_json(\"build/export_log.json\")\n",
    "\n",
    "    n = _outline_chapter_count()\n",
    "    missing = _missing_chapters(n) if n else []\n",
    "\n",
    "    snap = {\n",
    "        \"generated_at\": _now_iso(),\n",
    "        \"title\": outline.get(\"title\") or build_s.get(\"title\"),\n",
    "        \"chapters_planned\": n,\n",
    "        \"chapters_done\": (n - len(missing)) if n else None,\n",
    "        \"chapters_missing\": missing,\n",
    "        \"qa_outcome\": qa_rpt.get(\"outcome\") if isinstance(qa_rpt, dict) else None,\n",
    "        \"unresolved_per_10k\": (qa_rpt.get(\"metrics\") or {}).get(\"unresolved_per_10k_words\") if isinstance(qa_rpt, dict) else build_s.get(\"unresolved_per_10k_words\"),\n",
    "        \"gate_rollup\": ((qa_rpt.get(\"gate_metrics\") or {}).get(\"rollup\") if isinstance(qa_rpt, dict) else None),\n",
    "        \"exports\": {\"docx\": export.get(\"docx\"), \"epub\": export.get(\"epub\"), \"pdf\": export.get(\"pdf\")},\n",
    "        \"cost_summary\": manifest.get(\"cost_summary\"),\n",
    "        \"binaries\": {\"pandoc\": _pandoc_version(), \"pdf_engine\": _pdf_engine_detect()},\n",
    "        \"artifacts\": {\n",
    "            \"book_md\": Path(\"build/book.md\").exists(),\n",
    "            \"docx\": Path(\"dist/book.docx\").exists(),\n",
    "            \"epub\": Path(\"dist/book.epub\").exists(),\n",
    "            \"pdf\": Path(\"dist/book.pdf\").exists(),\n",
    "        },\n",
    "    }\n",
    "    Path(\"dist\").mkdir(parents=True, exist_ok=True)\n",
    "    Path(\"dist/triage_snapshot.json\").write_text(json.dumps(snap, ensure_ascii=False, indent=2), encoding=\"utf-8\")\n",
    "    return snap\n",
    "\n",
    "snap = triage_snapshot()\n",
    "print(\"\\n[triage] Snapshot written to dist/triage_snapshot.json\")\n",
    "\n",
    "# Optional: verify author calls if helper exists\n",
    "try:\n",
    "    verify_author_calls()\n",
    "except Exception as e:\n",
    "    print(\"\\n[triage] verify_author_calls() unavailable or failed:\", e)\n",
    "\n",
    "print(\"Triage ready\")"
   ]
  }
 ],
 "metadata": {
  "kernelspec": {
   "display_name": "Python 3",
   "language": "python",
   "name": "python3"
  },
  "language_info": {
   "codemirror_mode": {
    "name": "ipython",
    "version": 3
   },
   "file_extension": ".py",
   "mimetype": "text/x-python",
   "name": "python",
   "nbconvert_exporter": "python",
   "pygments_lexer": "ipython3",
   "version": "3.13.7"
  }
 },
 "nbformat": 4,
 "nbformat_minor": 5
}
